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 diff --git a/CLAUDE.md b/CLAUDE.md index b66f3df..8e461db 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,7 @@ deploy/ |------|------| | Runtime | Node.js 22, TypeScript 5.x | | Bot | discord.js v14, feedsmith (RSS 파서), pg-boss (PostgreSQL 잡 큐), Sentry (에러 모니터링) | -| Web | Next.js 16 App Router, React 19, shadcn/ui, Tailwind CSS v4, Tiptap (리치 에디터), sonner (토스트), Framer Motion (랜딩 애니메이션), Sentry (에러 모니터링) | +| Web | Next.js 16 App Router, React 19, shadcn/ui, Tailwind CSS v4, Tiptap (리치 에디터), sonner (토스트), Framer Motion (랜딩 애니메이션), Firebase (FCM 푸시 알림), Sentry (에러 모니터링) | | DB | Supabase PostgreSQL + Drizzle ORM (Transaction Pooler, `prepare: false`) | | Auth | Supabase Auth (Discord OAuth) + `@supabase/ssr` | | 배포 | AWS EC2 Docker (bot), Vercel (web), Supabase (DB + Auth) | @@ -104,6 +104,14 @@ pnpm --filter @blog-study/bot rss-collect # 수동 RSS 수집 (봇 없이) | `packages/web/src/app/api/admin/bot-operations/[operationId]/route.ts` | 봇 작업 트리거 프록시 (web → bot HTTP API, 30s 타임아웃) | | `packages/web/src/app/(admin)/admin/rounds/page.tsx` | 회차 관리 페이지 (CRUD + 현재 회차 설정) | | `packages/web/src/app/api/profile/withdraw/route.ts` | 유저 자체 탈퇴 API | +| `packages/web/src/lib/firebase/admin.ts` | Firebase Admin SDK (lazy 초기화, `getAdminMessaging()`) | +| `packages/web/src/lib/firebase/client.ts` | Firebase 클라이언트 (FCM 토큰 요청, 포그라운드 메시지) | +| `packages/web/src/lib/push.ts` | FCM 푸시 전송 (`sendPushToMember`, `sendPushToMembers`) | +| `packages/web/src/hooks/use-push-notification.ts` | 푸시 알림 훅 (권한 관리, 토큰 복원, 구독/해제) | +| `packages/web/src/components/settings/push-notification-settings.tsx` | 알림 설정 UI (타입별 토글 + 테스트 전송) | +| `packages/web/src/app/api/push/test/route.ts` | 테스트 푸시 알림 API (레이트 리밋 5/min) | +| `packages/web/src/app/api/notification-preferences/route.ts` | 알림 타입별 설정 CRUD API | +| `packages/web/public/firebase-messaging-sw.js` | FCM 서비스 워커 (백그라운드 알림 수신) | | `packages/bot/src/scripts/rss-collect.ts` | 수동 RSS 수집 스크립트 (봇 없이 독립 실행) | | `packages/bot/src/scripts/setup-channels.ts` | 디스코드 채널 일괄 생성 스크립트 | | `packages/bot/src/scripts/list-channels.ts` | 서버 채널 구조 조회 스크립트 | @@ -208,6 +216,8 @@ pnpm --filter @blog-study/bot rss-collect # 수동 RSS 수집 (봇 없이) - `NEXT_PUBLIC_SENTRY_DSN` (Sentry 에러 모니터링, web 전용) - `SENTRY_DSN` (Sentry 에러 모니터링, bot 전용) - `SENTRY_AUTH_TOKEN` (소스맵 업로드, Vercel/CI에서만 설정) +- `FIREBASE_PROJECT_ID`, `FIREBASE_PRIVATE_KEY`, `FIREBASE_CLIENT_EMAIL` 등 (Firebase Admin, 서버용) +- `NEXT_PUBLIC_FIREBASE_*` (Firebase 클라이언트, `API_KEY`/`AUTH_DOMAIN`/`PROJECT_ID`/`MESSAGING_SENDER_ID`/`APP_ID`/`VAPID_KEY`) **env 파일 위치** (2곳): - `.env.local` — 루트 (shared/bot용) diff --git a/docs/26-03-06-schema-summary.md b/docs/26-03-06-schema-summary.md index 93755d9..ec0c3fe 100644 --- a/docs/26-03-06-schema-summary.md +++ b/docs/26-03-06-schema-summary.md @@ -13,6 +13,7 @@ | `CurationCategory` | `conference`, `article` | 큐레이션 분류 | | `ActivityScoreType` | `blog_post`, `board_post`, `post_comment`, `board_comment`, `admin_manual`, `post_view` | 활동 점수 | | `BoardCategory` | `notice`, `suggestion`, `review`, `knowledge`, `daily`, `etc` | 게시판 카테고리 (const object) | +| `NotificationType` | `board_comment`, `board_reply`, `post_comment`, `post_reply`, `board_notice` | 알림 유형 (const object) | ## 테이블 @@ -133,6 +134,28 @@ | `deleted_at` | timestamptz | nullable (soft delete) | | 인덱스: `post_id`, `member_id`, `parent_id` | +### fcm_tokens +| 컬럼 | 타입 | 비고 | +|------|------|------| +| `id` | uuid PK | defaultRandom | +| `member_id` | uuid FK → members | not null | +| `token` | text | not null | +| `device_info` | text | nullable | +| `last_used_at` | timestamptz | defaultNow | +| unique constraint: `(member_id, token)` | +| 인덱스: `member_id` | + +### notification_preferences +| 컬럼 | 타입 | 비고 | +|------|------|------| +| `id` | uuid PK | defaultRandom | +| `member_id` | uuid FK → members | not null | +| `type` | varchar(30) | NotificationType, not null | +| `enabled` | boolean | default true | +| `updated_at` | timestamptz | defaultNow | +| unique constraint: `(member_id, type)` | +| 인덱스: `member_id` | + ### keywords, curation_sources, curation_items - `keywords`: keyword(unique) + frequency + last_updated - `curation_sources`: url(unique) + name + category + rss_url + tags[] + is_active @@ -155,8 +178,10 @@ members ──< board_posts (member_id) members ──< board_comments (member_id) board_posts ──< board_comments (post_id) board_comments ──< board_comments (parent_id, self-ref) +members ──< fcm_tokens (member_id) +members ──< notification_preferences (member_id) ``` ## 타입 Export -모든 테이블에 `Type`/`NewType` export 있음 (예: `Member`/`NewMember`, `Post`/`NewPost`, `BoardPost`/`NewBoardPost`, `BoardComment`/`NewBoardComment`) +모든 테이블에 `Type`/`NewType` export 있음 (예: `Member`/`NewMember`, `Post`/`NewPost`, `BoardPost`/`NewBoardPost`, `BoardComment`/`NewBoardComment`, `FcmToken`/`NewFcmToken`, `NotificationPreference`/`NewNotificationPreference`) 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/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e8fde9a..765bdd0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,6 @@ # Blog Study Admin - 시스템 아키텍처 -> 최종 업데이트: 2026-03-16 (v8) +> 최종 업데이트: 2026-03-17 (v9) 블로그 글쓰기 스터디 운영 자동화 플랫폼. 웹 대시보드에서 모든 관리/유저 기능을 제공하고, Discord 봇은 스케줄러(RSS 수집/출석/벌금/큐레이션)와 이벤트 핸들러만 담당한다. @@ -41,7 +41,7 @@ graph TB subgraph DB["Supabase · PostgreSQL"] AUTH["Supabase Auth
Discord OAuth"] - TABLES["members · posts · rounds
attendance · fines · config
keywords · curation · activity_scores
post_views · board_posts · board_comments"] + TABLES["members · posts · rounds
attendance · fines · config
keywords · curation · activity_scores
post_views · board_posts · board_comments
fcm_tokens · notification_preferences"] PGBOSS["pg-boss
Job Queue"] end @@ -51,7 +51,8 @@ graph TB PTR["Pull-to-Refresh
커스텀 터치 제스처"] BANNER["Notice Banner
글로벌 공지 배너"] PWA["PWA
manifest.json
홈 화면 추가"] - API["API Routes
/api/auth · /api/posts
/api/admin · /api/board · ..."] + FCM["FCM Push
firebase-admin · firebase/messaging
서비스 워커"] + API["API Routes
/api/auth · /api/posts
/api/admin · /api/board
/api/push · /api/notification-preferences"] SUPA_CLIENT["Supabase SSR Client
@supabase/ssr"] SENTRY_WEB["Sentry SDK
에러 모니터링 + PII 스크러빙"] end @@ -76,6 +77,12 @@ graph TB PAGES --> API SENTRY_WEB -->|tunnel /api/_sentry-tunnel| SENTRY SENTRY_BOT -->|HTTPS| SENTRY + subgraph Firebase["Firebase Cloud Messaging"] + FCM_CLOUD["FCM Server
푸시 알림 전송"] + end + + FCM -->|firebase-admin| FCM_CLOUD + FCM_CLOUD -->|push| Browser SENTRY -->|Alert| DISCORD_WH ``` @@ -98,6 +105,7 @@ mindmap sonner Toast Framer Motion 애니메이션 Sentry 에러 모니터링 + Firebase FCM 푸시 알림 PWA 홈 화면 추가 Supabase Auth Discord OAuth @@ -122,6 +130,7 @@ mindmap | **Web** | Next.js App Router + shadcn/ui | App Router의 서버 컴포넌트/API Route 통합. shadcn/ui는 커스터마이징 자유도 최고 | | **Bot** | discord.js v14 | 사실상 유일한 선택지. 안정적이고 문서 풍부 | | **Job Queue** | pg-boss | PostgreSQL 기반으로 추가 인프라 불필요. 트랜잭션 보장, 재시도/동시성 관리 | +| **Push** | Firebase Cloud Messaging | 무료 무제한 FCM. 서비스 워커 기반 백그라운드 알림. PWA 환경에 최적화 | | **Monorepo** | pnpm workspace | 빠른 설치, 엄격한 의존성 관리. shared 패키지로 스키마/타입 공유 | 상세 결정 근거: [`docs/26-03-06-tech-decisions.md`](./26-03-06-tech-decisions.md) @@ -239,6 +248,7 @@ flowchart TD | User | `/members` | 멤버 목록 | 로그인 필수 | | User | `/members/[id]` | 멤버 상세 | 로그인 필수 | | User | `/profile` | 프로필 | 로그인 필수 | +| User | `/profile/notifications` | 알림 설정 (푸시 토글 + 타입별 설정 + 테스트) | 로그인 필수 | | Admin | `/admin` | 관리자 대시보드 | 관리자 전용 | | Admin | `/admin/members` | 멤버 관리 | 관리자 전용 | | Admin | `/admin/rounds` | 회차 관리 | 관리자 전용 | @@ -289,6 +299,8 @@ erDiagram members ||--o{ post_views : "조회" members ||--o{ board_posts : "게시글" members ||--o{ board_comments : "댓글" + members ||--o{ fcm_tokens : "FCM토큰" + members ||--o{ notification_preferences : "알림설정" rounds ||--o{ posts : "회차" rounds ||--o{ attendance : "회차" rounds ||--o{ fines : "회차" @@ -383,6 +395,21 @@ erDiagram text[] tags } + fcm_tokens { + uuid id PK + uuid member_id FK + text token + text device_info + timestamp last_used_at + } + + notification_preferences { + uuid id PK + uuid member_id FK + varchar type + boolean enabled + } + curation_items { uuid id PK uuid source_id FK @@ -409,7 +436,8 @@ erDiagram - **공지 배너**: 전역 상단 스카이블루 배너 (`NoticeBanner`), 관리자가 공지 글에서 활성화 (1개만), 접기/닫기 localStorage 유지 - **Dialog/AlertDialog**: Safari PWA 스크롤 대응 — `flex flex-col` + `inset-y-0 my-auto` 센터링 + `overflow-y-auto` (grid/transform 방식은 Safari에서 클리핑 발생) - **Pull-to-Refresh**: 커스텀 터치 제스처 기반 새로고침 (`PullToRefresh` + `usePullToRefresh`), Safari PWA 최적화, 다이얼로그 열림 시 `data-scroll-locked` 가드로 비활성화 -- **PWA**: `manifest.json` + 커스텀 로고 아이콘 (SVG/192/512, maskable) → 홈 화면 추가 지원, 서비스 워커 없음 (lightweight) +- **PWA**: `manifest.json` + 커스텀 로고 아이콘 (SVG/192/512, maskable) → 홈 화면 추가 지원 +- **FCM 푸시**: Firebase Cloud Messaging 서비스 워커 (`firebase-messaging-sw.js`) → 백그라운드 알림. 타입별(댓글/답글/공지) 개별 설정, 테스트 알림 전송 지원 ## 스케줄러 (pg-boss) @@ -511,5 +539,7 @@ graph LR | Tiptap | 3.20 | Rich text editor | | shadcn/ui | latest | UI components | | Framer Motion | 12.x | Landing page animations | +| firebase | 11.x | FCM client (토큰 발급, 포그라운드 메시지) | +| firebase-admin | 13.x | FCM server (푸시 전송) | | @sentry/nextjs | 10.43 | Web error monitoring + source maps | | @sentry/node | 10.43 | Bot error monitoring | 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..13328a5 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -42,6 +42,8 @@ "date-fns": "^4.1.0", "drizzle-orm": "^0.33.0", "feedsmith": "^2.9.0", + "firebase": "^12.10.0", + "firebase-admin": "^13.7.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..8fbc120 --- /dev/null +++ b/packages/web/public/firebase-messaging-sw.js @@ -0,0 +1,54 @@ +// Firebase Cloud Messaging Service Worker +// Firebase client API key는 설계상 공개 식별자입니다 (Security Rules + 도메인 제한으로 보호). +// https://firebase.google.com/docs/projects/api-keys#api-keys-for-firebase-are-different + +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", +}; + +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) + ); +}); + +// 알림 클릭 처리 — clickUrl은 반드시 상대 경로만 허용 (오픈 리다이렉트 방지) +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + + const rawUrl = event.notification.data?.clickUrl || '/dashboard'; + const url = rawUrl.startsWith('/') ? rawUrl : '/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/notifications/page.tsx b/packages/web/src/app/(user)/profile/notifications/page.tsx new file mode 100644 index 0000000..713d22a --- /dev/null +++ b/packages/web/src/app/(user)/profile/notifications/page.tsx @@ -0,0 +1,19 @@ +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { PushNotificationSettings } from '@/components/settings/push-notification-settings'; + +export default function ProfileNotificationsPage() { + return ( +
+

알림 설정

+ + + +

푸시 알림

+
+ + + +
+
+ ); +} 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/[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/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/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/app/api/push/subscribe/route.ts b/packages/web/src/app/api/push/subscribe/route.ts new file mode 100644 index 0000000..dc1e86f --- /dev/null +++ b/packages/web/src/app/api/push/subscribe/route.ts @@ -0,0 +1,51 @@ +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; + +const MAX_TOKEN_LENGTH = 500; +const MAX_DEVICE_INFO_LENGTH = 200; + +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 || typeof token !== 'string') { + return Errors.badRequest('FCM 토큰이 필요합니다.').toResponse(); + } + + if (token.length > MAX_TOKEN_LENGTH) { + return Errors.badRequest('FCM 토큰이 너무 깁니다.').toResponse(); + } + + const sanitizedDeviceInfo = + typeof deviceInfo === 'string' ? deviceInfo.slice(0, MAX_DEVICE_INFO_LENGTH) : null; + + const database = getDb(); + + await database + .insert(fcmTokens) + .values({ + memberId: auth.memberId, + token, + deviceInfo: sanitizedDeviceInfo, + }) + .onConflictDoUpdate({ + target: [fcmTokens.memberId, fcmTokens.token], + set: { + lastUsedAt: new Date(), + deviceInfo: sanitizedDeviceInfo, + }, + }); + + return successResponse({ subscribed: true }, '알림이 구독되었습니다.'); + } catch (error) { + return errorResponse(error); + } +} diff --git a/packages/web/src/app/api/push/test/route.ts b/packages/web/src/app/api/push/test/route.ts new file mode 100644 index 0000000..534624f --- /dev/null +++ b/packages/web/src/app/api/push/test/route.ts @@ -0,0 +1,71 @@ +import { NextRequest } from 'next/server'; +import { getBoardAuth } from '@/lib/board-auth'; +import { sendPushToMember } from '@/lib/push'; +import { errorResponse, Errors, successResponse } from '@/lib/api-error'; + +const TEST_MESSAGES: Record = { + board_comment: { + title: '💬 게시판 댓글 테스트', + body: '누군가 내 게시글에 댓글을 남겼습니다.', + }, + board_reply: { + title: '↩️ 게시판 답글 테스트', + body: '누군가 내 댓글에 답글을 남겼습니다.', + }, + post_comment: { + title: '📝 포스트 댓글 테스트', + body: '누군가 내 포스트에 댓글을 남겼습니다.', + }, + post_reply: { + title: '↩️ 포스트 답글 테스트', + body: '누군가 내 포스트 댓글에 답글을 남겼습니다.', + }, + board_notice: { + title: '📢 공지사항 테스트', + body: '새로운 공지사항이 게시되었습니다.', + }, +}; + +// 유저별 레이트 리밋 (분당 5회) +const rateLimitMap = new Map(); +const RATE_LIMIT_WINDOW = 60_000; +const RATE_LIMIT_MAX = 5; + +function checkRateLimit(memberId: string): boolean { + const now = Date.now(); + const timestamps = rateLimitMap.get(memberId) ?? []; + const recent = timestamps.filter((t) => now - t < RATE_LIMIT_WINDOW); + if (recent.length >= RATE_LIMIT_MAX) return false; + recent.push(now); + rateLimitMap.set(memberId, recent); + return true; +} + +export async function POST(request: NextRequest) { + try { + const auth = await getBoardAuth(); + if (!auth) return Errors.unauthorized().toResponse(); + + if (!checkRateLimit(auth.memberId)) { + return Errors.badRequest('테스트 알림은 1분에 5회까지 가능합니다.').toResponse(); + } + + const { type } = await request.json(); + + const message = TEST_MESSAGES[type]; + if (!message) { + return Errors.badRequest('잘못된 알림 타입입니다.').toResponse(); + } + + const result = await sendPushToMember(auth.memberId, { + title: message.title, + body: message.body, + clickUrl: '/profile/notifications', + data: { type }, + }); + + return successResponse(result, '테스트 알림이 전송되었습니다.'); + } 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/settings/push-notification-settings.tsx b/packages/web/src/components/settings/push-notification-settings.tsx new file mode 100644 index 0000000..735f398 --- /dev/null +++ b/packages/web/src/components/settings/push-notification-settings.tsx @@ -0,0 +1,216 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Bell, Megaphone, MessageCircle, MessageSquare, SendHorizonal } 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; +} + +type IconComponent = React.ComponentType<{ className?: string }>; + +const NOTIFICATION_LABELS: Record< + string, + { label: string; icon: IconComponent; description: string } +> = { + 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); + const [sendingTest, setSendingTest] = useState(null); + + const handleTestPush = async (type: string) => { + setSendingTest(type); + try { + const res = await fetch('/api/push/test', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type }), + }); + const data = await res.json(); + if (!data.success) { + throw new Error(data.message || '테스트 알림 전송에 실패했습니다.'); + } + if (data.data?.success > 0) { + toast.success('테스트 알림이 전송되었습니다.'); + } else { + toast.error('알림 전송에 실패했습니다. Firebase 설정을 확인해주세요.'); + } + } catch { + toast.error('테스트 알림 전송에 실패했습니다.'); + } finally { + setSendingTest(null); + } + }; + + // 알림 설정 불러오기 + 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 snapshot = preferences; + setPreferences((current) => current.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 { + setPreferences(snapshot); + 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)} + aria-labelledby={`label-${pref.type}`} + /> +
+
+ ); + })} +
+ )} +
+ )} +
+ ); +} 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/packages/web/src/hooks/use-push-notification.ts b/packages/web/src/hooks/use-push-notification.ts new file mode 100644 index 0000000..74b67a8 --- /dev/null +++ b/packages/web/src/hooks/use-push-notification.ts @@ -0,0 +1,117 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { toast } from 'sonner'; +import { onForegroundMessage, requestFCMToken } from '@/lib/firebase/client'; + +const PUSH_UNSUBSCRIBED_KEY = 'push-unsubscribed'; + +export function usePushNotification() { + const [permission, setPermission] = useState( + typeof window !== 'undefined' && 'Notification' in window ? Notification.permission : 'default' + ); + const [token, setToken] = useState(null); + + // 권한이 granted이고 명시적으로 해제하지 않았으면 토큰 자동 복원 + 서버 재구독 + useEffect(() => { + const unsubscribed = localStorage.getItem(PUSH_UNSUBSCRIBED_KEY) === 'true'; + if ('Notification' in window && Notification.permission === 'granted' && !unsubscribed) { + requestFCMToken().then((fcmToken) => { + if (fcmToken) { + setToken(fcmToken); + subscribeToPush(fcmToken); + } + }); + } + }, []); + + 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); + localStorage.removeItem(PUSH_UNSUBSCRIBED_KEY); + await subscribeToPush(fcmToken); + toast.success('알림이 활성화되었습니다.'); + return true; + } + } + + if (result === 'denied') { + toast.error('알림이 차단되었습니다. 브라우저 설정에서 변경해주세요.'); + } + + return false; + }; + + const subscribeToPush = async (fcmToken: string) => { + try { + const res = await fetch('/api/push/subscribe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: fcmToken, + deviceInfo: navigator.userAgent.slice(0, 200), + }), + }); + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + const data = await res.json(); + if (!data.success) { + throw new Error(data.message || '구독 실패'); + } + } catch (error) { + console.error('Push subscription failed:', error); + toast.error('알림 구독에 실패했습니다.'); + } + }; + + const unsubscribe = async () => { + // 토큰이 없으면 재발급 시도 후 삭제 + let currentToken = token; + if (!currentToken) { + currentToken = await requestFCMToken(); + } + + if (currentToken) { + await fetch('/api/push/unsubscribe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: currentToken }), + }); + } + + setToken(null); + localStorage.setItem(PUSH_UNSUBSCRIBED_KEY, 'true'); + toast.success('알림이 비활성화되었습니다.'); + }; + + return { + permission, + token, + requestPermission, + unsubscribe, + isSupported: typeof window !== 'undefined' && 'Notification' in window, + }; +} diff --git a/packages/web/src/lib/firebase/admin.ts b/packages/web/src/lib/firebase/admin.ts new file mode 100644 index 0000000..544fbeb --- /dev/null +++ b/packages/web/src/lib/firebase/admin.ts @@ -0,0 +1,48 @@ +import admin from 'firebase-admin'; +import { getApps } from 'firebase-admin/app'; + +let initialized = false; + +function ensureInitialized(): boolean { + if (initialized || getApps().length > 0) { + initialized = true; + return true; + } + + try { + 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 (!serviceAccount.project_id || !serviceAccount.private_key || !serviceAccount.client_email) { + throw new Error('Required Firebase environment variables are missing'); + } + + admin.initializeApp({ + credential: admin.credential.cert(serviceAccount as admin.ServiceAccount), + }); + + console.log('[Firebase] Admin SDK initialized successfully'); + initialized = true; + return true; + } catch (error) { + console.error('[Firebase] Failed to initialize Firebase Admin:', error); + return false; + } +} + +export function getAdminMessaging() { + if (!ensureInitialized()) { + return null; + } + return admin.messaging(); +} diff --git a/packages/web/src/lib/firebase/client.ts b/packages/web/src/lib/firebase/client.ts new file mode 100644 index 0000000..266f6cf --- /dev/null +++ b/packages/web/src/lib/firebase/client.ts @@ -0,0 +1,70 @@ +import { initializeApp, getApps, type FirebaseApp } from 'firebase/app'; +import { getMessaging, getToken, onMessage, type Messaging } 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, +}; + +let app: FirebaseApp | null = null; +let messagingInstance: Messaging | null = null; + +function getApp(): FirebaseApp { + if (!app) { + app = !getApps().length ? initializeApp(firebaseConfig) : getApps()[0]!; + } + return app; +} + +function getMessagingInstance(): Messaging { + if (!messagingInstance) { + messagingInstance = getMessaging(getApp()); + } + return messagingInstance; +} + +async function registerServiceWorker() { + if (typeof window !== 'undefined' && 'serviceWorker' in navigator) { + try { + const registration = await navigator.serviceWorker.register( + '/firebase-messaging-sw.js', + { type: 'classic' } + ); + return registration; + } catch (error) { + console.error('[FCM] Service Worker registration failed:', error); + return null; + } + } + return 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(getMessagingInstance(), { + vapidKey: process.env.NEXT_PUBLIC_FIREBASE_VAPID_KEY, + serviceWorkerRegistration: registration, + }); + return token; + } catch (error) { + console.error('[FCM] Token request failed:', error); + return null; + } +} + +export function onForegroundMessage( + callback: (payload: { notification?: { title?: string; body?: string } }) => void +): () => void { + return onMessage(getMessagingInstance(), callback); +} diff --git a/packages/web/src/lib/push.ts b/packages/web/src/lib/push.ts new file mode 100644 index 0000000..3912b65 --- /dev/null +++ b/packages/web/src/lib/push.ts @@ -0,0 +1,258 @@ +import { eq, inArray, and } from 'drizzle-orm'; +import { getDb } from '@/lib/db'; +import { db as sharedDb } from '@blog-study/shared'; +import { getAdminMessaging } 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), + }; + + const messaging = getAdminMessaging(); + if (!messaging) { + console.warn('[push] Firebase Admin not initialized, skipping push'); + return { success: 0, failed: tokens.length }; + } + + try { + const response = await messaging.sendEachForMulticast(message); + + const failedTokens: string[] = []; + const succeededTokens: string[] = []; + response.responses.forEach((resp: { success: boolean }, idx: number) => { + if (resp.success) { + succeededTokens.push(tokens[idx]!.token); + } else { + failedTokens.push(tokens[idx]!.token); + } + }); + + // 실패한 토큰 삭제 (memberId 스코프) + if (failedTokens.length > 0) { + await database + .delete(fcmTokens) + .where(and( + eq(fcmTokens.memberId, memberId), + inArray(fcmTokens.token, failedTokens) + )); + } + + // 성공한 토큰만 마지막 사용 시간 업데이트 + if (succeededTokens.length > 0) { + await database + .update(fcmTokens) + .set({ lastUsedAt: new Date() }) + .where(and( + eq(fcmTokens.memberId, memberId), + inArray(fcmTokens.token, succeededTokens) + )); + } + + 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 notificationType = payload.data?.type; + let filteredMemberIds = memberIds; + if (notificationType) { + const disabledPrefs = await database + .select({ memberId: notificationPreferences.memberId }) + .from(notificationPreferences) + .where( + and( + inArray(notificationPreferences.memberId, memberIds), + eq(notificationPreferences.type, notificationType), + eq(notificationPreferences.enabled, false) + ) + ); + const disabledSet = new Set(disabledPrefs.map((p) => p.memberId)); + filteredMemberIds = memberIds.filter((id) => !disabledSet.has(id)); + } + + if (filteredMemberIds.length === 0) { + return { success: 0, failed: 0 }; + } + + const tokens = await database + .select({ token: fcmTokens.token, memberId: fcmTokens.memberId }) + .from(fcmTokens) + .where(inArray(fcmTokens.memberId, filteredMemberIds)); + + 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 messaging = getAdminMessaging(); + if (!messaging) { + totalFailed += tokenList.length; + continue; + } + + const response = await messaging.sendEachForMulticast(message); + totalSuccess += response.successCount; + totalFailed += response.failureCount; + + const failedTokens: string[] = []; + const succeededTokens: string[] = []; + response.responses.forEach((resp: { success: boolean }, idx: number) => { + if (resp.success) { + succeededTokens.push(tokenList[idx]!); + } else { + failedTokens.push(tokenList[idx]!); + } + }); + + // 실패한 토큰 삭제 (memberId 스코프) + if (failedTokens.length > 0) { + await database + .delete(fcmTokens) + .where(and( + eq(fcmTokens.memberId, memberId), + inArray(fcmTokens.token, failedTokens) + )); + } + + // 성공한 토큰만 마지막 사용 시간 업데이트 + if (succeededTokens.length > 0) { + await database + .update(fcmTokens) + .set({ lastUsedAt: new Date() }) + .where(and( + eq(fcmTokens.memberId, memberId), + inArray(fcmTokens.token, succeededTokens) + )); + } + } catch (error) { + console.error(`[push] Failed to send to ${memberId}:`, error); + totalFailed += tokenList.length; + } + } + + return { success: totalSuccess, failed: totalFailed }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a5b662..8a7eebc 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,12 @@ importers: feedsmith: specifier: ^2.9.0 version: 2.9.0 + firebase: + specifier: ^12.10.0 + version: 12.10.0 + firebase-admin: + specifier: ^13.7.0 + version: 13.7.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 +1057,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 +1290,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 +1490,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 +1517,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 +1806,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 +1820,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 +2912,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 +2959,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 +3009,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 +3021,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 +3318,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 +3360,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 +3390,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 +3402,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 +3456,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 +3470,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 +3502,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 +3539,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 +3613,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 +3707,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 +3923,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 +4197,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 +4219,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 +4267,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 +4283,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 +4307,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 +4341,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 +4402,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 +4432,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 +4473,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 +4506,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 +4536,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 +4590,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 +4621,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 +4705,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 +4755,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 +4797,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 +4808,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 +4830,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 +4857,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 +4963,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 +4991,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 +5040,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 +5050,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 +5118,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 +5213,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 +5231,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 +5257,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 +5327,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 +5366,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 +5606,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 +5704,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 +5724,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 +5757,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 +5774,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 +5797,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 +5949,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 +5986,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 +6019,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 +6066,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 +6306,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 +6396,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 +6420,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 +6470,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 +6497,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 +7069,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 +7081,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 +7416,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 +7606,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 +7643,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 +7967,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 +7981,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 +9081,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 +9094,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 +9142,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 +9159,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 +9197,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 +9218,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 +9573,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 +9609,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 +9643,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 +9732,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 +9770,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 +9817,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 +9907,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 +9979,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 +10124,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 +10669,9 @@ snapshots: etag@1.8.1: {} + event-target-shim@5.0.1: + optional: true + events@3.3.0: {} execa@8.0.1: @@ -9524,6 +10724,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 +10770,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 +10783,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 +10816,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 +10893,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 +10916,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 +10954,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 +11048,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 +11094,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 +11206,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 +11224,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 +11243,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 + idb@7.1.1: {} + ignore@5.3.2: {} ignore@7.0.5: {} @@ -9916,6 +11330,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 +11376,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 +11420,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 +11434,8 @@ snapshots: jiti@2.6.1: {} + jose@4.15.9: {} + joycon@3.1.1: {} js-tokens@4.0.0: {} @@ -10021,6 +11448,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 +11468,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 +11488,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 +11575,8 @@ snapshots: lilconfig@3.1.3: {} + limiter@1.1.5: {} + lines-and-columns@1.2.4: {} linkify-it@5.0.0: @@ -10131,12 +11598,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 +11638,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 +11705,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 +11794,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 +11807,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 +11829,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 +11915,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 +11946,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 +12229,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 +12330,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 +12361,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 +12393,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 +12468,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 +12665,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 +12735,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 +12762,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 +12800,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 +13062,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 +13213,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 +13253,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 +13324,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: {}