From e0acdff939b9503a4e7bfca4ce2c34864e3f1c31 Mon Sep 17 00:00:00 2001 From: GRACENOBLE Date: Tue, 16 Jun 2026 02:11:38 +0300 Subject: [PATCH] feat(fcm): integrate Firebase Cloud Messaging for push notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Refactor pkg/firebase to share a single App instance between auth and messaging clients (NewApp → NewAuthClient + NewMessagingClient) - Add NotificationSender and FCMTokenRepository interfaces in usecase/ - Add FCMToken domain entity and fcm_tokens migration (uuid PK, user_id, token UNIQUE, platform, created_at) with user_id index - Implement FCMTokenRepository in infrastructure/database/postgres with upsert semantics on token conflict - Add POST /api/v1/fcm/register and DELETE /api/v1/fcm/unregister handlers, guarded by FirebaseAuth middleware; wired only when Firebase is configured - Wire FCMSender and FCMTokenRepository through bootstrap.App → server → Handler - Add 5 handler unit tests (mock repo) and 4 repository integration tests (Testcontainers / real PostgreSQL) - Regenerate Swagger docs with FCM endpoint annotations Web: - Install firebase JS SDK - Add lib/fcm.ts: requestPermissionAndGetToken() and onForegroundMessage() - Add lib/useFCM.ts hook: requests permission, registers token with backend - Add public/firebase-messaging-sw.js service worker for background messages - Add 4 Vitest unit tests (vi.hoisted mocks for firebase/app and firebase/messaging) Mobile: - Add Firebase BOM 33.7.0 + firebase-messaging-ktx to version catalog and build.gradle - Add MyFirebaseMessagingService: onNewToken (posts to backend), onMessageReceived (shows NotificationCompat notification, channel creation guarded for API 26+) - Add FcmRegistrationPayload (@Serializable data class) - Register service in AndroidManifest with MESSAGING_EVENT intent filter - Add POST_NOTIFICATIONS and INTERNET permissions - Add 4 JUnit unit tests for FcmRegistrationPayload serialisation Docs: - Fix stale backend/docs/auth.md (verifier wiring was incorrect) - Create backend/docs/fcm.md, web/docs/fcm.md, mobile/docs/fcm.md - Update all three _index.md files Closes #19 --- backend/docs/_index.md | 1 + backend/docs/auth.md | 11 +- backend/docs/fcm.md | 174 ++++ backend/docs/swagger/docs.go | 185 ++++ backend/docs/swagger/swagger.json | 185 ++++ backend/docs/swagger/swagger.yaml | 119 +++ backend/internal/bootstrap/bootstrap.go | 42 +- backend/internal/domain/fcm_token.go | 12 + .../20260615220942_add_fcm_tokens.sql | 13 + .../database/postgres/fcm_token_repository.go | 62 ++ .../postgres/fcm_token_repository_test.go | 98 +++ backend/internal/server/server.go | 3 +- .../transport/handlers/fcm_handler.go | 90 ++ .../transport/handlers/fcm_handler_test.go | 125 +++ .../internal/transport/handlers/handler.go | 26 +- .../transport/handlers/health_handler_test.go | 4 +- backend/internal/transport/handlers/routes.go | 5 + backend/internal/usecase/notification.go | 20 + backend/pkg/firebase/admin.go | 24 +- backend/pkg/firebase/app.go | 25 + backend/pkg/firebase/messaging.go | 53 ++ mobile/app/build.gradle.kts | 2 + mobile/app/src/main/AndroidManifest.xml | 12 + .../template/fcm/FcmRegistrationPayload.kt | 9 + .../fcm/MyFirebaseMessagingService.kt | 74 ++ .../fcm/FcmRegistrationPayloadTest.kt | 42 + mobile/docs/_index.md | 3 +- mobile/docs/fcm.md | 117 +++ mobile/gradle/libs.versions.toml | 3 + web/docs/_index.md | 1 + web/docs/fcm.md | 96 +++ web/lib/fcm.test.ts | 92 ++ web/lib/fcm.ts | 47 ++ web/lib/useFCM.ts | 44 + web/package.json | 1 + web/pnpm-lock.yaml | 796 ++++++++++++++++++ web/public/firebase-messaging-sw.js | 17 + 37 files changed, 2580 insertions(+), 53 deletions(-) create mode 100644 backend/docs/fcm.md create mode 100644 backend/internal/domain/fcm_token.go create mode 100644 backend/internal/infrastructure/database/migrations/20260615220942_add_fcm_tokens.sql create mode 100644 backend/internal/infrastructure/database/postgres/fcm_token_repository.go create mode 100644 backend/internal/infrastructure/database/postgres/fcm_token_repository_test.go create mode 100644 backend/internal/transport/handlers/fcm_handler.go create mode 100644 backend/internal/transport/handlers/fcm_handler_test.go create mode 100644 backend/internal/usecase/notification.go create mode 100644 backend/pkg/firebase/app.go create mode 100644 backend/pkg/firebase/messaging.go create mode 100644 mobile/app/src/main/java/com/company/template/fcm/FcmRegistrationPayload.kt create mode 100644 mobile/app/src/main/java/com/company/template/fcm/MyFirebaseMessagingService.kt create mode 100644 mobile/app/src/test/java/com/company/template/fcm/FcmRegistrationPayloadTest.kt create mode 100644 mobile/docs/fcm.md create mode 100644 web/docs/fcm.md create mode 100644 web/lib/fcm.test.ts create mode 100644 web/lib/fcm.ts create mode 100644 web/lib/useFCM.ts create mode 100644 web/public/firebase-messaging-sw.js diff --git a/backend/docs/_index.md b/backend/docs/_index.md index 52b3b15..b49ba1e 100644 --- a/backend/docs/_index.md +++ b/backend/docs/_index.md @@ -18,3 +18,4 @@ The `docs` agent reads this index first to locate the right file before diving i | WebSocket (Hub, client, GET /ws, auth, wiring) | [websocket.md](websocket.md) | `internal/infrastructure/ws/`, `internal/transport/handlers/ws_handler.go`, `internal/transport/handlers/routes.go`, `internal/server/server.go`, `cmd/api/main.go` | | Background job queue (Asynq, task definitions, worker, Asynqmon UI) | [queue.md](queue.md) | `internal/usecase/enqueuer.go`, `internal/infrastructure/queue/tasks.go`, `internal/infrastructure/queue/client.go`, `internal/infrastructure/queue/worker.go`, `internal/infrastructure/queue/handlers.go`, `internal/transport/handlers/routes.go`, `cmd/api/main.go` | | Redis Streams event fan-out (producer, consumer, consumer groups) | [streams.md](streams.md) | `internal/infrastructure/streams/events.go`, `internal/infrastructure/streams/producer.go`, `internal/infrastructure/streams/consumer.go` | +| Firebase Cloud Messaging — token storage, send API, FCM endpoints | [fcm.md](fcm.md) | `internal/domain/fcm_token.go`, `internal/usecase/notification.go`, `internal/infrastructure/database/postgres/fcm_token_repository.go`, `internal/transport/handlers/fcm_handler.go`, `pkg/firebase/app.go`, `pkg/firebase/messaging.go` | diff --git a/backend/docs/auth.md b/backend/docs/auth.md index 59fef79..123de3b 100644 --- a/backend/docs/auth.md +++ b/backend/docs/auth.md @@ -5,6 +5,9 @@ sources: - internal/usecase/auth_usecase.go - internal/transport/middleware/auth.go - internal/transport/handlers/auth_handler.go + - internal/transport/handlers/handler.go + - internal/transport/handlers/routes.go + - internal/server/server.go - pkg/firebase/admin.go - internal/bootstrap/bootstrap.go --- @@ -94,17 +97,17 @@ func (h *Handler) MeHandler(c *gin.Context) - Returns `200 OK` with the token struct serialised as JSON. - Returns `401 Unauthorized` with `{"error": "unauthorized"}` if the context value is missing or of the wrong type (should not happen when `FirebaseAuth` is applied to the group). -The handler is registered on the `/api/v1` group in `RegisterRoutes`: +The handler is registered on the `/api/v1` group in `RegisterRoutes`. The verifier is stored on the `Handler` struct (via `NewHandler`) and guarded inline: ```go api := r.Group("/api/v1") -if verifier != nil { - api.Use(middleware.FirebaseAuth(verifier)) +if h.verifier != nil { + api.Use(middleware.FirebaseAuth(h.verifier)) } api.GET("/me", h.MeHandler) ``` ## Disabling auth in development -When `FIREBASE_PROJECT_ID` is not set `bootstrap.Run` skips Firebase initialisation and `app.Firebase` is `nil`. `server.NewServer` passes `app.Firebase` directly to `RegisterRoutes` as the `verifier` argument. When `verifier` is `nil` the `if verifier != nil` guard in `RegisterRoutes` skips `api.Use(middleware.FirebaseAuth(...))`, so `/api/v1/me` is reachable without a token. +When `FIREBASE_PROJECT_ID` is not set `bootstrap.Run` skips Firebase initialisation and `app.Firebase` is `nil`. `server.NewServer` passes `app.Firebase` to `NewHandler` where it is stored as `h.verifier`. When `h.verifier` is `nil` the guard in `RegisterRoutes` skips `api.Use(middleware.FirebaseAuth(...))`, so `/api/v1/me` is reachable without a token. To enable auth locally set both `FIREBASE_PROJECT_ID` and `FIREBASE_SERVICE_ACCOUNT_JSON` in `backend/.env`. diff --git a/backend/docs/fcm.md b/backend/docs/fcm.md new file mode 100644 index 0000000..a2582f3 --- /dev/null +++ b/backend/docs/fcm.md @@ -0,0 +1,174 @@ +--- +topic: fcm +last_verified: 2026-06-16 +sources: + - internal/domain/fcm_token.go + - internal/usecase/notification.go + - internal/infrastructure/database/postgres/fcm_token_repository.go + - internal/infrastructure/database/postgres/fcm_token_repository_test.go + - internal/transport/handlers/fcm_handler.go + - internal/transport/handlers/fcm_handler_test.go + - internal/transport/handlers/routes.go + - internal/transport/handlers/handler.go + - internal/server/server.go + - internal/bootstrap/bootstrap.go + - pkg/firebase/app.go + - pkg/firebase/admin.go + - pkg/firebase/messaging.go + - internal/infrastructure/database/migrations/20260615220942_add_fcm_tokens.sql +--- + +# Firebase Cloud Messaging (FCM) + +FCM sends push notifications to Android, iOS, and web clients. The backend stores per-user device tokens and sends notifications via the Firebase Admin SDK using the FCM HTTP v1 API. + +## Domain entity + +`internal/domain/fcm_token.go`: +```go +type FCMToken struct { + ID string + UserID string + Token string + Platform string // "android" | "ios" | "web" + CreatedAt time.Time +} +``` + +## Usecase interfaces + +Both interfaces live in `internal/usecase/notification.go`. + +```go +// NotificationSender sends FCM push notifications via the Admin SDK. +type NotificationSender interface { + SendToToken(ctx context.Context, token, title, body string, data map[string]string) error + SendMulticast(ctx context.Context, tokens []string, title, body string, data map[string]string) error +} + +// FCMTokenRepository persists device registration tokens per user. +type FCMTokenRepository interface { + SaveToken(ctx context.Context, userID, token, platform string) error + GetTokensByUserID(ctx context.Context, userID string) ([]domain.FCMToken, error) + DeleteToken(ctx context.Context, token string) error +} +``` + +`NotificationSender` is the narrow port used by any use case that needs to push a notification. `FCMTokenRepository` is the persistence port used by the HTTP handlers. + +## pkg/firebase — shared app initialisation + +`pkg/firebase/app.go` creates the single Firebase Admin SDK app instance: + +```go +func NewApp(ctx context.Context, projectID, credentialsJSON string) (*firebasesdk.App, error) +``` + +The same `*firebasesdk.App` is passed to both `NewAuthClient` and `NewMessagingClient` so the SDK initialises only once per process. Calling `firebasesdk.NewApp` twice with default settings returns an error, hence the shared app pattern. + +`pkg/firebase/admin.go` — `NewAuthClient(ctx, app)` now accepts the pre-created app instead of creating its own. + +`pkg/firebase/messaging.go` — `NewMessagingClient(ctx, app)` wraps `*messaging.Client`: + +```go +func NewMessagingClient(ctx context.Context, app *firebasesdk.App) (usecase.NotificationSender, error) +``` + +`SendMulticast` uses `client.SendEachForMulticast` and returns an error if any individual message fails. + +## bootstrap.App + +`internal/bootstrap/bootstrap.go` — `App` now carries `FCMSender usecase.NotificationSender`. Both Firebase clients share the same SDK app: + +```go +if cfg.FirebaseProjectID != "" { + fbApp, _ := firebase.NewApp(ctx, cfg.FirebaseProjectID, cfg.FirebaseServiceAccountJSON) + authClient, _ := firebase.NewAuthClient(ctx, fbApp) + msgClient, _ := firebase.NewMessagingClient(ctx, fbApp) + app.Firebase = authClient + app.FCMSender = msgClient +} +``` + +When `FIREBASE_PROJECT_ID` is not set both fields are `nil` and FCM routes are not registered. + +## Database — fcm_tokens table + +Migration `20260615220942_add_fcm_tokens.sql`: +```sql +CREATE TABLE IF NOT EXISTS fcm_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id TEXT NOT NULL, + token TEXT NOT NULL UNIQUE, + platform TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_fcm_tokens_user_id ON fcm_tokens(user_id); +``` + +`SaveToken` uses an upsert (`ON CONFLICT (token) DO UPDATE`) so a physical device re-registering or switching accounts is handled atomically without a duplicate token. + +## HTTP endpoints + +Both routes are registered inside the `/api/v1` group and inherit the `FirebaseAuth` middleware when `h.verifier != nil`. They are only registered when `h.fcmTokenRepo != nil`: + +```go +if h.fcmTokenRepo != nil { + api.POST("/fcm/register", h.RegisterFCMToken) + api.DELETE("/fcm/unregister", h.UnregisterFCMToken) +} +``` + +### POST /api/v1/fcm/register + +Request body: +```json +{ "token": "", "platform": "android|ios|web" } +``` + +Reads `UID` from `*usecase.FirebaseToken` in the Gin context (set by `FirebaseAuth` middleware), then calls `SaveToken`. Returns `200 {"message": "token registered"}`. + +### DELETE /api/v1/fcm/unregister + +Request body: +```json +{ "token": "" } +``` + +Calls `DeleteToken`. Returns `200 {"message": "token unregistered"}`. Designed to be called on logout. + +## Wiring in server.go + +```go +fcmTokenRepo := postgres.NewFCMTokenRepository(app.DB) +h := handlers.NewHandler(..., app.FCMSender, fcmTokenRepo) +``` + +`NewFCMTokenRepository` always returns a valid repository backed by `app.DB`; the FCM routes are gated by `h.fcmTokenRepo != nil` on the router side, but since `NewFCMTokenRepository` is always called, the routes are always registered when the server starts. The `FirebaseAuth` middleware is the actual auth gate. + +## Sending notifications from other use cases + +Inject `usecase.NotificationSender` into any use case that needs to push a notification: + +```go +// Example: notify all user devices after a background job completes +tokens, _ := fcmTokenRepo.GetTokensByUserID(ctx, userID) +fcmTokens := make([]string, len(tokens)) +for i, t := range tokens { fcmTokens[i] = t.Token } +_ = fcmSender.SendMulticast(ctx, fcmTokens, "Job done", "Your report is ready", nil) +``` + +## Testing + +**Handler unit tests** (`internal/transport/handlers/fcm_handler_test.go`): `mockFCMTokenRepo` implements `usecase.FCMTokenRepository`; no database or Firebase SDK involved. + +**Repository integration tests** (`internal/infrastructure/database/postgres/fcm_token_repository_test.go`): use Testcontainers (real PostgreSQL). Each test calls `setupFCMTokensTable` to create the table and registers a cleanup to drop it. Shares the `testDB` global and `TestMain` from `health_repository_test.go` — do not add another `TestMain` to this package. + +## Environment variables + +| Variable | Required | Description | +|---|---|---| +| `FIREBASE_PROJECT_ID` | No | Firebase project ID (e.g. `my-app-12345`). Omit to disable Firebase entirely. | +| `FIREBASE_SERVICE_ACCOUNT_JSON` | No | Raw service account JSON string. Omit to use Application Default Credentials. | + +Both are already documented in `backend/.env.example`. diff --git a/backend/docs/swagger/docs.go b/backend/docs/swagger/docs.go index 7d4855d..d32ab96 100644 --- a/backend/docs/swagger/docs.go +++ b/backend/docs/swagger/docs.go @@ -56,6 +56,160 @@ const docTemplate = `{ } } }, + "/api/v1/fcm/register": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Saves a device FCM registration token for the authenticated user. If the token already exists it is upserted.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "fcm" + ], + "summary": "Register FCM token", + "parameters": [ + { + "description": "Token registration payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.registerFCMTokenRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + }, + "/api/v1/fcm/unregister": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Removes a device FCM registration token. Typically called on logout or device change.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "fcm" + ], + "summary": "Unregister FCM token", + "parameters": [ + { + "description": "Token to remove", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.unregisterFCMTokenRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + }, "/api/v1/me": { "get": { "security": [ @@ -231,6 +385,37 @@ const docTemplate = `{ "type": "string" } } + }, + "handlers.registerFCMTokenRequest": { + "type": "object", + "required": [ + "platform", + "token" + ], + "properties": { + "platform": { + "type": "string", + "enum": [ + "android", + "ios", + "web" + ] + }, + "token": { + "type": "string" + } + } + }, + "handlers.unregisterFCMTokenRequest": { + "type": "object", + "required": [ + "token" + ], + "properties": { + "token": { + "type": "string" + } + } } }, "securityDefinitions": { diff --git a/backend/docs/swagger/swagger.json b/backend/docs/swagger/swagger.json index 89156fd..aae1a9e 100644 --- a/backend/docs/swagger/swagger.json +++ b/backend/docs/swagger/swagger.json @@ -50,6 +50,160 @@ } } }, + "/api/v1/fcm/register": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Saves a device FCM registration token for the authenticated user. If the token already exists it is upserted.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "fcm" + ], + "summary": "Register FCM token", + "parameters": [ + { + "description": "Token registration payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.registerFCMTokenRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + }, + "/api/v1/fcm/unregister": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Removes a device FCM registration token. Typically called on logout or device change.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "fcm" + ], + "summary": "Unregister FCM token", + "parameters": [ + { + "description": "Token to remove", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.unregisterFCMTokenRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + }, "/api/v1/me": { "get": { "security": [ @@ -225,6 +379,37 @@ "type": "string" } } + }, + "handlers.registerFCMTokenRequest": { + "type": "object", + "required": [ + "platform", + "token" + ], + "properties": { + "platform": { + "type": "string", + "enum": [ + "android", + "ios", + "web" + ] + }, + "token": { + "type": "string" + } + } + }, + "handlers.unregisterFCMTokenRequest": { + "type": "object", + "required": [ + "token" + ], + "properties": { + "token": { + "type": "string" + } + } } }, "securityDefinitions": { diff --git a/backend/docs/swagger/swagger.yaml b/backend/docs/swagger/swagger.yaml index c744dc8..746ce21 100644 --- a/backend/docs/swagger/swagger.yaml +++ b/backend/docs/swagger/swagger.yaml @@ -37,6 +37,27 @@ definitions: wait_duration: type: string type: object + handlers.registerFCMTokenRequest: + properties: + platform: + enum: + - android + - ios + - web + type: string + token: + type: string + required: + - platform + - token + type: object + handlers.unregisterFCMTokenRequest: + properties: + token: + type: string + required: + - token + type: object host: localhost:8080 info: contact: {} @@ -70,6 +91,104 @@ paths: summary: Asynq job monitoring UI tags: - observability + /api/v1/fcm/register: + post: + consumes: + - application/json + description: Saves a device FCM registration token for the authenticated user. + If the token already exists it is upserted. + parameters: + - description: Token registration payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.registerFCMTokenRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + properties: + message: + type: string + type: object + "400": + description: Bad Request + schema: + properties: + error: + type: string + type: object + "401": + description: Unauthorized + schema: + properties: + error: + type: string + type: object + "500": + description: Internal Server Error + schema: + properties: + error: + type: string + type: object + security: + - BearerAuth: [] + summary: Register FCM token + tags: + - fcm + /api/v1/fcm/unregister: + delete: + consumes: + - application/json + description: Removes a device FCM registration token. Typically called on logout + or device change. + parameters: + - description: Token to remove + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.unregisterFCMTokenRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + properties: + message: + type: string + type: object + "400": + description: Bad Request + schema: + properties: + error: + type: string + type: object + "401": + description: Unauthorized + schema: + properties: + error: + type: string + type: object + "500": + description: Internal Server Error + schema: + properties: + error: + type: string + type: object + security: + - BearerAuth: [] + summary: Unregister FCM token + tags: + - fcm /api/v1/me: get: description: 'Returns the authenticated Firebase user''s decoded claims. Requires diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index 3d4eb2f..dd4c899 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -34,12 +34,13 @@ const ( // App holds all initialised, validated shared dependencies. // Constructed once by Run and passed to the HTTP server. type App struct { - DB *sql.DB - Cache usecase.CacheService // nil when REDIS_URL is not set - Enqueuer usecase.Enqueuer // nil when REDIS_URL is not set - Firebase usecase.FirebaseAdminClient // nil when FIREBASE_PROJECT_ID is not set - Config Config - Log *slog.Logger + DB *sql.DB + Cache usecase.CacheService // nil when REDIS_URL is not set + Enqueuer usecase.Enqueuer // nil when REDIS_URL is not set + Firebase usecase.FirebaseAdminClient // nil when FIREBASE_PROJECT_ID is not set + FCMSender usecase.NotificationSender // nil when FIREBASE_PROJECT_ID is not set + Config Config + Log *slog.Logger } // Config holds all validated configuration values read from environment variables. @@ -118,24 +119,35 @@ func Run(ctx context.Context) (*App, error) { } var firebaseClient usecase.FirebaseAdminClient + var fcmSender usecase.NotificationSender if cfg.FirebaseProjectID != "" { - client, err := firebase.NewAuthClient(ctx, cfg.FirebaseProjectID, cfg.FirebaseServiceAccountJSON) + fbApp, err := firebase.NewApp(ctx, cfg.FirebaseProjectID, cfg.FirebaseServiceAccountJSON) if err != nil { return nil, fmt.Errorf("bootstrap: firebase: %w", err) } - firebaseClient = client - log.Info("bootstrap: firebase auth client initialised", "project_id", cfg.FirebaseProjectID) + authClient, err := firebase.NewAuthClient(ctx, fbApp) + if err != nil { + return nil, fmt.Errorf("bootstrap: firebase auth: %w", err) + } + firebaseClient = authClient + msgClient, err := firebase.NewMessagingClient(ctx, fbApp) + if err != nil { + return nil, fmt.Errorf("bootstrap: firebase messaging: %w", err) + } + fcmSender = msgClient + log.Info("bootstrap: firebase clients initialised", "project_id", cfg.FirebaseProjectID) } log.Info("bootstrap: all checks passed — ready to serve") return &App{ - DB: db, - Cache: cache, - Enqueuer: enqueuer, - Firebase: firebaseClient, - Config: cfg, - Log: log, + DB: db, + Cache: cache, + Enqueuer: enqueuer, + Firebase: firebaseClient, + FCMSender: fcmSender, + Config: cfg, + Log: log, }, nil } diff --git a/backend/internal/domain/fcm_token.go b/backend/internal/domain/fcm_token.go new file mode 100644 index 0000000..d196218 --- /dev/null +++ b/backend/internal/domain/fcm_token.go @@ -0,0 +1,12 @@ +package domain + +import "time" + +// FCMToken is a Firebase Cloud Messaging device registration token stored per user. +type FCMToken struct { + ID string + UserID string + Token string + Platform string + CreatedAt time.Time +} diff --git a/backend/internal/infrastructure/database/migrations/20260615220942_add_fcm_tokens.sql b/backend/internal/infrastructure/database/migrations/20260615220942_add_fcm_tokens.sql new file mode 100644 index 0000000..f3d0347 --- /dev/null +++ b/backend/internal/infrastructure/database/migrations/20260615220942_add_fcm_tokens.sql @@ -0,0 +1,13 @@ +-- +goose Up +CREATE TABLE IF NOT EXISTS fcm_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id TEXT NOT NULL, + token TEXT NOT NULL UNIQUE, + platform TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_fcm_tokens_user_id ON fcm_tokens(user_id); + +-- +goose Down +DROP INDEX IF EXISTS idx_fcm_tokens_user_id; +DROP TABLE IF EXISTS fcm_tokens; diff --git a/backend/internal/infrastructure/database/postgres/fcm_token_repository.go b/backend/internal/infrastructure/database/postgres/fcm_token_repository.go new file mode 100644 index 0000000..a884320 --- /dev/null +++ b/backend/internal/infrastructure/database/postgres/fcm_token_repository.go @@ -0,0 +1,62 @@ +package postgres + +import ( + "context" + "database/sql" + "fmt" + + "backend/internal/domain" +) + +// FCMTokenRepository implements usecase.FCMTokenRepository against PostgreSQL. +type FCMTokenRepository struct{ db *sql.DB } + +// NewFCMTokenRepository creates a new FCMTokenRepository. +func NewFCMTokenRepository(db *sql.DB) *FCMTokenRepository { + return &FCMTokenRepository{db: db} +} + +// SaveToken persists a device token for a user. If the token already exists it is +// updated (upserted) so that a single physical device is always associated with +// exactly one user. +func (r *FCMTokenRepository) SaveToken(ctx context.Context, userID, token, platform string) error { + const q = ` + INSERT INTO fcm_tokens (user_id, token, platform) + VALUES ($1, $2, $3) + ON CONFLICT (token) DO UPDATE + SET user_id = EXCLUDED.user_id, + platform = EXCLUDED.platform` + if _, err := r.db.ExecContext(ctx, q, userID, token, platform); err != nil { + return fmt.Errorf("fcm_token_repository: save: %w", err) + } + return nil +} + +// GetTokensByUserID returns all FCM tokens registered for a user. +func (r *FCMTokenRepository) GetTokensByUserID(ctx context.Context, userID string) ([]domain.FCMToken, error) { + const q = `SELECT id, user_id, token, platform, created_at FROM fcm_tokens WHERE user_id = $1` + rows, err := r.db.QueryContext(ctx, q, userID) + if err != nil { + return nil, fmt.Errorf("fcm_token_repository: get: %w", err) + } + defer rows.Close() + + var tokens []domain.FCMToken + for rows.Next() { + var t domain.FCMToken + if err := rows.Scan(&t.ID, &t.UserID, &t.Token, &t.Platform, &t.CreatedAt); err != nil { + return nil, fmt.Errorf("fcm_token_repository: scan: %w", err) + } + tokens = append(tokens, t) + } + return tokens, rows.Err() +} + +// DeleteToken removes a single FCM token, typically called on logout. +func (r *FCMTokenRepository) DeleteToken(ctx context.Context, token string) error { + const q = `DELETE FROM fcm_tokens WHERE token = $1` + if _, err := r.db.ExecContext(ctx, q, token); err != nil { + return fmt.Errorf("fcm_token_repository: delete: %w", err) + } + return nil +} diff --git a/backend/internal/infrastructure/database/postgres/fcm_token_repository_test.go b/backend/internal/infrastructure/database/postgres/fcm_token_repository_test.go new file mode 100644 index 0000000..af814b0 --- /dev/null +++ b/backend/internal/infrastructure/database/postgres/fcm_token_repository_test.go @@ -0,0 +1,98 @@ +package postgres + +import ( + "context" + "testing" +) + +// setupFCMTokensTable creates the fcm_tokens table for tests and drops it afterwards. +// Integration tests do NOT rely on migrations — each test sets up its own schema. +func setupFCMTokensTable(t *testing.T) { + t.Helper() + _, err := testDB.Exec(` + CREATE TABLE IF NOT EXISTS fcm_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id TEXT NOT NULL, + token TEXT NOT NULL UNIQUE, + platform TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + )`) + if err != nil { + t.Fatalf("setupFCMTokensTable: %v", err) + } + t.Cleanup(func() { testDB.Exec("DROP TABLE IF EXISTS fcm_tokens") }) //nolint:errcheck +} + +func TestFCMTokenRepository_SaveAndGet(t *testing.T) { + setupFCMTokensTable(t) + ctx := context.Background() + repo := NewFCMTokenRepository(testDB) + + if err := repo.SaveToken(ctx, "user1", "tok-abc", "android"); err != nil { + t.Fatalf("SaveToken: %v", err) + } + + tokens, err := repo.GetTokensByUserID(ctx, "user1") + if err != nil { + t.Fatalf("GetTokensByUserID: %v", err) + } + if len(tokens) != 1 { + t.Fatalf("expected 1 token, got %d", len(tokens)) + } + if tokens[0].Token != "tok-abc" || tokens[0].Platform != "android" || tokens[0].UserID != "user1" { + t.Errorf("unexpected token: %+v", tokens[0]) + } +} + +func TestFCMTokenRepository_Upsert(t *testing.T) { + setupFCMTokensTable(t) + ctx := context.Background() + repo := NewFCMTokenRepository(testDB) + + if err := repo.SaveToken(ctx, "userA", "tok-upsert", "web"); err != nil { + t.Fatalf("first SaveToken: %v", err) + } + // Same physical token, different user — upsert should reassign it. + if err := repo.SaveToken(ctx, "userB", "tok-upsert", "web"); err != nil { + t.Fatalf("second SaveToken (upsert): %v", err) + } + + tokensA, _ := repo.GetTokensByUserID(ctx, "userA") + tokensB, _ := repo.GetTokensByUserID(ctx, "userB") + if len(tokensA) != 0 { + t.Errorf("expected userA to have 0 tokens after upsert, got %d", len(tokensA)) + } + if len(tokensB) != 1 { + t.Errorf("expected userB to have 1 token, got %d", len(tokensB)) + } +} + +func TestFCMTokenRepository_Delete(t *testing.T) { + setupFCMTokensTable(t) + ctx := context.Background() + repo := NewFCMTokenRepository(testDB) + + _ = repo.SaveToken(ctx, "user2", "tok-del", "ios") + if err := repo.DeleteToken(ctx, "tok-del"); err != nil { + t.Fatalf("DeleteToken: %v", err) + } + + tokens, _ := repo.GetTokensByUserID(ctx, "user2") + if len(tokens) != 0 { + t.Errorf("expected 0 tokens after delete, got %d", len(tokens)) + } +} + +func TestFCMTokenRepository_GetEmpty(t *testing.T) { + setupFCMTokensTable(t) + ctx := context.Background() + repo := NewFCMTokenRepository(testDB) + + tokens, err := repo.GetTokensByUserID(ctx, "nonexistent") + if err != nil { + t.Fatalf("GetTokensByUserID: %v", err) + } + if len(tokens) != 0 { + t.Errorf("expected 0 tokens, got %d", len(tokens)) + } +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 80a5dcf..7ad57af 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -34,6 +34,7 @@ func NewServer(app *bootstrap.App, hub *ws.Hub) (*http.Server, error) { healthRepo := postgres.NewHealthRepository(app.DB) healthUC := usecase.NewHealthUseCase(healthRepo) + fcmTokenRepo := postgres.NewFCMTokenRepository(app.DB) // Build Asynqmon UI handler when Redis is available. var queueUI http.Handler @@ -51,7 +52,7 @@ func NewServer(app *bootstrap.App, hub *ws.Hub) (*http.Server, error) { } } - h := handlers.NewHandler(healthUC, app.Firebase, hub, app.Enqueuer, queueUI) + h := handlers.NewHandler(healthUC, app.Firebase, hub, app.Enqueuer, queueUI, app.FCMSender, fcmTokenRepo) // Register DB pool metrics collector. // AlreadyRegisteredError is silenced — only the first registration wins diff --git a/backend/internal/transport/handlers/fcm_handler.go b/backend/internal/transport/handlers/fcm_handler.go new file mode 100644 index 0000000..a02ae1c --- /dev/null +++ b/backend/internal/transport/handlers/fcm_handler.go @@ -0,0 +1,90 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "backend/internal/transport/middleware" + "backend/internal/usecase" +) + +type registerFCMTokenRequest struct { + Token string `json:"token" binding:"required"` + Platform string `json:"platform" binding:"required,oneof=android ios web"` +} + +type unregisterFCMTokenRequest struct { + Token string `json:"token" binding:"required"` +} + +// RegisterFCMToken saves a device FCM registration token for the authenticated user. +// +// @Summary Register FCM token +// @Description Saves a device FCM registration token for the authenticated user. If the token already exists it is upserted. +// @Tags fcm +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param body body registerFCMTokenRequest true "Token registration payload" +// @Success 200 {object} object{message=string} +// @Failure 400 {object} object{error=string} +// @Failure 401 {object} object{error=string} +// @Failure 500 {object} object{error=string} +// @Router /api/v1/fcm/register [post] +func (h *Handler) RegisterFCMToken(c *gin.Context) { + val, _ := c.Get(middleware.FirebaseClaimsKey) + claims, ok := val.(*usecase.FirebaseToken) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + + var req registerFCMTokenRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := h.fcmTokenRepo.SaveToken(c.Request.Context(), claims.UID, req.Token, req.Platform); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save token"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "token registered"}) +} + +// UnregisterFCMToken removes an FCM device token, typically called on logout. +// +// @Summary Unregister FCM token +// @Description Removes a device FCM registration token. Typically called on logout or device change. +// @Tags fcm +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param body body unregisterFCMTokenRequest true "Token to remove" +// @Success 200 {object} object{message=string} +// @Failure 400 {object} object{error=string} +// @Failure 401 {object} object{error=string} +// @Failure 500 {object} object{error=string} +// @Router /api/v1/fcm/unregister [delete] +func (h *Handler) UnregisterFCMToken(c *gin.Context) { + val, _ := c.Get(middleware.FirebaseClaimsKey) + if _, ok := val.(*usecase.FirebaseToken); !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + + var req unregisterFCMTokenRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := h.fcmTokenRepo.DeleteToken(c.Request.Context(), req.Token); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove token"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "token unregistered"}) +} diff --git a/backend/internal/transport/handlers/fcm_handler_test.go b/backend/internal/transport/handlers/fcm_handler_test.go new file mode 100644 index 0000000..95460ea --- /dev/null +++ b/backend/internal/transport/handlers/fcm_handler_test.go @@ -0,0 +1,125 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + + "backend/internal/domain" + "backend/internal/transport/middleware" + "backend/internal/usecase" +) + +type mockFCMTokenRepo struct { + savedUserID string + savedToken string + savedPlatform string + deletedToken string + tokens []domain.FCMToken + saveErr error + deleteErr error +} + +func (m *mockFCMTokenRepo) SaveToken(_ context.Context, userID, token, platform string) error { + m.savedUserID, m.savedToken, m.savedPlatform = userID, token, platform + return m.saveErr +} +func (m *mockFCMTokenRepo) GetTokensByUserID(_ context.Context, _ string) ([]domain.FCMToken, error) { + return m.tokens, nil +} +func (m *mockFCMTokenRepo) DeleteToken(_ context.Context, token string) error { + m.deletedToken = token + return m.deleteErr +} + +func newFCMRouter(h *Handler) *gin.Engine { + r := gin.New() + injectUID := func(c *gin.Context) { + c.Set(middleware.FirebaseClaimsKey, &usecase.FirebaseToken{UID: "uid123"}) + c.Next() + } + r.POST("/api/v1/fcm/register", injectUID, h.RegisterFCMToken) + r.DELETE("/api/v1/fcm/unregister", injectUID, h.UnregisterFCMToken) + return r +} + +func TestRegisterFCMToken_Success(t *testing.T) { + repo := &mockFCMTokenRepo{} + h := &Handler{fcmTokenRepo: repo} + r := newFCMRouter(h) + + body, _ := json.Marshal(map[string]string{"token": "test-token", "platform": "android"}) + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/v1/fcm/register", bytes.NewReader(body))) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if repo.savedToken != "test-token" || repo.savedPlatform != "android" || repo.savedUserID != "uid123" { + t.Errorf("unexpected saved values: token=%s platform=%s uid=%s", repo.savedToken, repo.savedPlatform, repo.savedUserID) + } +} + +func TestRegisterFCMToken_InvalidPlatform(t *testing.T) { + repo := &mockFCMTokenRepo{} + h := &Handler{fcmTokenRepo: repo} + r := newFCMRouter(h) + + body, _ := json.Marshal(map[string]string{"token": "test-token", "platform": "unknown"}) + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/v1/fcm/register", bytes.NewReader(body))) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestRegisterFCMToken_NoAuth(t *testing.T) { + h := &Handler{fcmTokenRepo: &mockFCMTokenRepo{}} + r := gin.New() + r.POST("/api/v1/fcm/register", h.RegisterFCMToken) + + body, _ := json.Marshal(map[string]string{"token": "t", "platform": "web"}) + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/v1/fcm/register", bytes.NewReader(body))) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", w.Code) + } +} + +func TestUnregisterFCMToken_Success(t *testing.T) { + repo := &mockFCMTokenRepo{} + h := &Handler{fcmTokenRepo: repo} + r := newFCMRouter(h) + + body, _ := json.Marshal(map[string]string{"token": "del-token"}) + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodDelete, "/api/v1/fcm/unregister", bytes.NewReader(body))) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if repo.deletedToken != "del-token" { + t.Errorf("expected del-token to be deleted, got %q", repo.deletedToken) + } +} + +func TestUnregisterFCMToken_NoAuth(t *testing.T) { + h := &Handler{fcmTokenRepo: &mockFCMTokenRepo{}} + r := gin.New() + r.DELETE("/api/v1/fcm/unregister", h.UnregisterFCMToken) + + body, _ := json.Marshal(map[string]string{"token": "t"}) + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodDelete, "/api/v1/fcm/unregister", bytes.NewReader(body))) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", w.Code) + } +} diff --git a/backend/internal/transport/handlers/handler.go b/backend/internal/transport/handlers/handler.go index de74329..a87cff4 100644 --- a/backend/internal/transport/handlers/handler.go +++ b/backend/internal/transport/handlers/handler.go @@ -9,11 +9,13 @@ import ( // Handler holds all use case dependencies for HTTP handlers. type Handler struct { - healthUC usecase.HealthUseCase - verifier usecase.FirebaseTokenVerifier // nil disables auth (dev only) - hub *ws.Hub - enqueuer usecase.Enqueuer // nil when REDIS_URL is not set - queueUI http.Handler // nil disables /admin/queues route + healthUC usecase.HealthUseCase + verifier usecase.FirebaseTokenVerifier // nil disables auth (dev only) + hub *ws.Hub + enqueuer usecase.Enqueuer // nil when REDIS_URL is not set + queueUI http.Handler // nil disables /admin/queues route + fcmSender usecase.NotificationSender // nil when Firebase is not configured + fcmTokenRepo usecase.FCMTokenRepository // nil when Firebase is not configured } // NewHandler constructs a Handler with all required use cases. @@ -23,12 +25,16 @@ func NewHandler( hub *ws.Hub, enqueuer usecase.Enqueuer, queueUI http.Handler, + fcmSender usecase.NotificationSender, + fcmTokenRepo usecase.FCMTokenRepository, ) *Handler { return &Handler{ - healthUC: healthUC, - verifier: verifier, - hub: hub, - enqueuer: enqueuer, - queueUI: queueUI, + healthUC: healthUC, + verifier: verifier, + hub: hub, + enqueuer: enqueuer, + queueUI: queueUI, + fcmSender: fcmSender, + fcmTokenRepo: fcmTokenRepo, } } diff --git a/backend/internal/transport/handlers/health_handler_test.go b/backend/internal/transport/handlers/health_handler_test.go index ec008a6..7b75e84 100644 --- a/backend/internal/transport/handlers/health_handler_test.go +++ b/backend/internal/transport/handlers/health_handler_test.go @@ -31,7 +31,7 @@ func TestHealthHandler_Success(t *testing.T) { Status: "up", Message: "It's healthy", } - h := NewHandler(&mockHealthUC{stats: want}, nil, nil, nil, nil) + h := NewHandler(&mockHealthUC{stats: want}, nil, nil, nil, nil, nil, nil) r := gin.New() r.GET("/health", h.HealthHandler) @@ -50,7 +50,7 @@ func TestHealthHandler_Success(t *testing.T) { } func TestHealthHandler_ServiceUnavailable(t *testing.T) { - h := NewHandler(&mockHealthUC{err: errors.New("connection refused")}, nil, nil, nil, nil) + h := NewHandler(&mockHealthUC{err: errors.New("connection refused")}, nil, nil, nil, nil, nil, nil) r := gin.New() r.GET("/health", h.HealthHandler) diff --git a/backend/internal/transport/handlers/routes.go b/backend/internal/transport/handlers/routes.go index ab56763..9c565a3 100644 --- a/backend/internal/transport/handlers/routes.go +++ b/backend/internal/transport/handlers/routes.go @@ -65,5 +65,10 @@ func (h *Handler) RegisterRoutes(rps float64, burst int, sentryDSN string) http. } api.GET("/me", h.MeHandler) + if h.fcmTokenRepo != nil { + api.POST("/fcm/register", h.RegisterFCMToken) + api.DELETE("/fcm/unregister", h.UnregisterFCMToken) + } + return r } diff --git a/backend/internal/usecase/notification.go b/backend/internal/usecase/notification.go new file mode 100644 index 0000000..4a3ba98 --- /dev/null +++ b/backend/internal/usecase/notification.go @@ -0,0 +1,20 @@ +package usecase + +import ( + "context" + + "backend/internal/domain" +) + +// NotificationSender sends FCM push notifications. +type NotificationSender interface { + SendToToken(ctx context.Context, token, title, body string, data map[string]string) error + SendMulticast(ctx context.Context, tokens []string, title, body string, data map[string]string) error +} + +// FCMTokenRepository persists device registration tokens. +type FCMTokenRepository interface { + SaveToken(ctx context.Context, userID, token, platform string) error + GetTokensByUserID(ctx context.Context, userID string) ([]domain.FCMToken, error) + DeleteToken(ctx context.Context, token string) error +} diff --git a/backend/pkg/firebase/admin.go b/backend/pkg/firebase/admin.go index 27d7d32..c66cf62 100644 --- a/backend/pkg/firebase/admin.go +++ b/backend/pkg/firebase/admin.go @@ -6,7 +6,6 @@ import ( firebasesdk "firebase.google.com/go/v4" "firebase.google.com/go/v4/auth" - "google.golang.org/api/option" "backend/internal/usecase" ) @@ -17,29 +16,14 @@ type authClientAdapter struct { client *auth.Client } -// NewAuthClient initialises the Firebase Admin SDK and returns a usecase.FirebaseAdminClient -// ready for injection. -// -// credentialsJSON is the raw content of a service account JSON key -// (FIREBASE_SERVICE_ACCOUNT_JSON env var). When empty the SDK falls back to -// Application Default Credentials (ADC) — appropriate for GCP-hosted deployments. -func NewAuthClient(ctx context.Context, projectID, credentialsJSON string) (usecase.FirebaseAdminClient, error) { - var opts []option.ClientOption - if credentialsJSON != "" { - opts = append(opts, option.WithCredentialsJSON([]byte(credentialsJSON))) - } - - cfg := &firebasesdk.Config{ProjectID: projectID} - app, err := firebasesdk.NewApp(ctx, cfg, opts...) - if err != nil { - return nil, fmt.Errorf("firebase: init app: %w", err) - } - +// NewAuthClient returns a usecase.FirebaseAdminClient from an already-initialised Firebase app. +// Use NewApp to create the app so that the same SDK instance can be shared with other clients +// (e.g. messaging). +func NewAuthClient(ctx context.Context, app *firebasesdk.App) (usecase.FirebaseAdminClient, error) { client, err := app.Auth(ctx) if err != nil { return nil, fmt.Errorf("firebase: init auth client: %w", err) } - return &authClientAdapter{client: client}, nil } diff --git a/backend/pkg/firebase/app.go b/backend/pkg/firebase/app.go new file mode 100644 index 0000000..521a3d7 --- /dev/null +++ b/backend/pkg/firebase/app.go @@ -0,0 +1,25 @@ +package firebase + +import ( + "context" + "fmt" + + firebasesdk "firebase.google.com/go/v4" + "google.golang.org/api/option" +) + +// NewApp initialises the Firebase Admin SDK default app. +// credentialsJSON is the raw service account JSON (FIREBASE_SERVICE_ACCOUNT_JSON). +// When empty the SDK falls back to Application Default Credentials (ADC). +func NewApp(ctx context.Context, projectID, credentialsJSON string) (*firebasesdk.App, error) { + var opts []option.ClientOption + if credentialsJSON != "" { + opts = append(opts, option.WithCredentialsJSON([]byte(credentialsJSON))) + } + cfg := &firebasesdk.Config{ProjectID: projectID} + app, err := firebasesdk.NewApp(ctx, cfg, opts...) + if err != nil { + return nil, fmt.Errorf("firebase: init app: %w", err) + } + return app, nil +} diff --git a/backend/pkg/firebase/messaging.go b/backend/pkg/firebase/messaging.go new file mode 100644 index 0000000..f53ffe5 --- /dev/null +++ b/backend/pkg/firebase/messaging.go @@ -0,0 +1,53 @@ +package firebase + +import ( + "context" + "fmt" + + firebasesdk "firebase.google.com/go/v4" + "firebase.google.com/go/v4/messaging" + + "backend/internal/usecase" +) + +type messagingAdapter struct { + client *messaging.Client +} + +// NewMessagingClient returns a usecase.NotificationSender backed by FCM HTTP v1. +// Use NewApp to create the app so the same SDK instance is shared with NewAuthClient. +func NewMessagingClient(ctx context.Context, app *firebasesdk.App) (usecase.NotificationSender, error) { + client, err := app.Messaging(ctx) + if err != nil { + return nil, fmt.Errorf("firebase: init messaging client: %w", err) + } + return &messagingAdapter{client: client}, nil +} + +func (m *messagingAdapter) SendToToken(ctx context.Context, token, title, body string, data map[string]string) error { + msg := &messaging.Message{ + Token: token, + Notification: &messaging.Notification{Title: title, Body: body}, + Data: data, + } + if _, err := m.client.Send(ctx, msg); err != nil { + return fmt.Errorf("fcm: send to token: %w", err) + } + return nil +} + +func (m *messagingAdapter) SendMulticast(ctx context.Context, tokens []string, title, body string, data map[string]string) error { + msg := &messaging.MulticastMessage{ + Tokens: tokens, + Notification: &messaging.Notification{Title: title, Body: body}, + Data: data, + } + br, err := m.client.SendEachForMulticast(ctx, msg) + if err != nil { + return fmt.Errorf("fcm: send multicast: %w", err) + } + if br.FailureCount > 0 { + return fmt.Errorf("fcm: %d/%d messages failed", br.FailureCount, len(tokens)) + } + return nil +} diff --git a/mobile/app/build.gradle.kts b/mobile/app/build.gradle.kts index 55aab6f..e359f1b 100644 --- a/mobile/app/build.gradle.kts +++ b/mobile/app/build.gradle.kts @@ -58,6 +58,8 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.sentry.android) + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.messaging.ktx) implementation(libs.okhttp) implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.serialization.json) diff --git a/mobile/app/src/main/AndroidManifest.xml b/mobile/app/src/main/AndroidManifest.xml index 03da5d4..033e472 100644 --- a/mobile/app/src/main/AndroidManifest.xml +++ b/mobile/app/src/main/AndroidManifest.xml @@ -2,6 +2,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/mobile/app/src/main/java/com/company/template/fcm/FcmRegistrationPayload.kt b/mobile/app/src/main/java/com/company/template/fcm/FcmRegistrationPayload.kt new file mode 100644 index 0000000..f41f71e --- /dev/null +++ b/mobile/app/src/main/java/com/company/template/fcm/FcmRegistrationPayload.kt @@ -0,0 +1,9 @@ +package com.company.template.fcm + +import kotlinx.serialization.Serializable + +@Serializable +data class FcmRegistrationPayload( + val token: String, + val platform: String = "android", +) diff --git a/mobile/app/src/main/java/com/company/template/fcm/MyFirebaseMessagingService.kt b/mobile/app/src/main/java/com/company/template/fcm/MyFirebaseMessagingService.kt new file mode 100644 index 0000000..45dd344 --- /dev/null +++ b/mobile/app/src/main/java/com/company/template/fcm/MyFirebaseMessagingService.kt @@ -0,0 +1,74 @@ +package com.company.template.fcm + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.os.Build +import androidx.core.app.NotificationCompat +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody + +class MyFirebaseMessagingService : FirebaseMessagingService() { + + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val httpClient = OkHttpClient() + + override fun onNewToken(token: String) { + serviceScope.launch { + registerTokenWithBackend(token) + } + } + + override fun onMessageReceived(message: RemoteMessage) { + message.notification?.let { notif -> + showNotification(notif.title.orEmpty(), notif.body.orEmpty()) + } + } + + private fun registerTokenWithBackend(token: String) { + val backendUrl = getString( + applicationContext.resources.getIdentifier( + "backend_base_url", "string", packageName + ).takeIf { it != 0 } ?: return + ) + val payload = FcmRegistrationPayload(token = token) + val body = Json.encodeToString(FcmRegistrationPayload.serializer(), payload) + .toRequestBody("application/json".toMediaType()) + val request = Request.Builder() + .url("$backendUrl/api/v1/fcm/register") + .post(body) + .build() + runCatching { httpClient.newCall(request).execute().close() } + } + + private fun showNotification(title: String, body: String) { + val channelId = "fcm_default" + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + channelId, + "Push Notifications", + NotificationManager.IMPORTANCE_DEFAULT, + ) + manager.createNotificationChannel(channel) + } + + val notification = NotificationCompat.Builder(this, channelId) + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setContentTitle(title) + .setContentText(body) + .setAutoCancel(true) + .build() + manager.notify(System.currentTimeMillis().toInt(), notification) + } +} diff --git a/mobile/app/src/test/java/com/company/template/fcm/FcmRegistrationPayloadTest.kt b/mobile/app/src/test/java/com/company/template/fcm/FcmRegistrationPayloadTest.kt new file mode 100644 index 0000000..de0da15 --- /dev/null +++ b/mobile/app/src/test/java/com/company/template/fcm/FcmRegistrationPayloadTest.kt @@ -0,0 +1,42 @@ +package com.company.template.fcm + +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class FcmRegistrationPayloadTest { + + private val json = Json { encodeDefaults = true } + + @Test + fun serializes_token_and_platform() { + val payload = FcmRegistrationPayload(token = "test-fcm-token-123") + val encoded = json.encodeToString(FcmRegistrationPayload.serializer(), payload) + + assertTrue("JSON must contain token field", encoded.contains("\"token\"")) + assertTrue("JSON must contain platform field", encoded.contains("\"platform\"")) + assertTrue("JSON must contain token value", encoded.contains("test-fcm-token-123")) + assertTrue("JSON must contain android platform", encoded.contains("\"android\"")) + } + + @Test + fun default_platform_is_android() { + val payload = FcmRegistrationPayload(token = "tok") + assertEquals("android", payload.platform) + } + + @Test + fun platform_can_be_overridden() { + val payload = FcmRegistrationPayload(token = "tok", platform = "ios") + assertEquals("ios", payload.platform) + } + + @Test + fun roundtrip_serialization() { + val original = FcmRegistrationPayload(token = "round-trip-token", platform = "android") + val encoded = json.encodeToString(FcmRegistrationPayload.serializer(), original) + val decoded = json.decodeFromString(FcmRegistrationPayload.serializer(), encoded) + assertEquals(original, decoded) + } +} diff --git a/mobile/docs/_index.md b/mobile/docs/_index.md index 9312112..84e42ff 100644 --- a/mobile/docs/_index.md +++ b/mobile/docs/_index.md @@ -1,6 +1,6 @@ --- topic: index -last_verified: 2026-06-14 +last_verified: 2026-06-16 --- # Mobile docs index @@ -13,3 +13,4 @@ Topic-based documentation for the Android app. Each file is kept in sync with th | Activity and Compose architecture | `architecture.md` | `app/src/main/java/com/company/template/MainActivity.kt`, `app/build.gradle.kts` | | Testing patterns | `testing.md` | `app/src/test/java/com/company/template/GreetingFormatTest.kt`, `app/src/androidTest/java/com/company/template/GreetingTest.kt`, `app/build.gradle.kts` | | Observability (Sentry error tracking) | `observability.md` | `gradle/libs.versions.toml`, `app/build.gradle.kts`, `app/src/main/java/com/company/template/MainActivity.kt` | +| Firebase Cloud Messaging — service, token registration, background notifications | `fcm.md` | `app/src/main/java/com/company/template/fcm/MyFirebaseMessagingService.kt`, `app/src/main/java/com/company/template/fcm/FcmRegistrationPayload.kt`, `app/src/main/AndroidManifest.xml`, `gradle/libs.versions.toml` | diff --git a/mobile/docs/fcm.md b/mobile/docs/fcm.md new file mode 100644 index 0000000..daa4555 --- /dev/null +++ b/mobile/docs/fcm.md @@ -0,0 +1,117 @@ +--- +topic: fcm +last_verified: 2026-06-16 +sources: + - app/src/main/java/com/company/template/fcm/FcmRegistrationPayload.kt + - app/src/main/java/com/company/template/fcm/MyFirebaseMessagingService.kt + - app/src/main/AndroidManifest.xml + - app/src/test/java/com/company/template/fcm/FcmRegistrationPayloadTest.kt + - gradle/libs.versions.toml + - app/build.gradle.kts +--- + +# Firebase Cloud Messaging (FCM) — Android + +Handles foreground and background push notifications via `FirebaseMessagingService`. + +## Dependencies + +`gradle/libs.versions.toml`: +```toml +[versions] +firebaseBom = "33.7.0" + +[libraries] +firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } +firebase-messaging-ktx = { group = "com.google.firebase", name = "firebase-messaging-ktx" } +``` + +`app/build.gradle.kts`: +```kotlin +implementation(platform(libs.firebase.bom)) +implementation(libs.firebase.messaging.ktx) +``` + +> **Note:** The Google Services Gradle plugin (`com.google.gms.google-services`) and a `google-services.json` file are required for Firebase to initialise at runtime. Add the plugin to `build.gradle.kts` and place `google-services.json` in `app/` when setting up a real Firebase project. Without them the app compiles but Firebase will not initialise. + +## AndroidManifest.xml + +```xml + + + + + + + + +``` + +`POST_NOTIFICATIONS` is required on Android 13+ (API 33+) for runtime notification permission. `INTERNET` is required to POST the token to the backend. + +## FcmRegistrationPayload + +`com.company.template.fcm.FcmRegistrationPayload` is a `@Serializable` data class representing the JSON body sent to `POST /api/v1/fcm/register`: + +```kotlin +@Serializable +data class FcmRegistrationPayload( + val token: String, + val platform: String = "android", +) +``` + +Serialised with `kotlinx.serialization.json.Json`. Platform defaults to `"android"`. + +## MyFirebaseMessagingService + +`com.company.template.fcm.MyFirebaseMessagingService` extends `FirebaseMessagingService`. + +### onNewToken + +Called by the FCM SDK when a new or refreshed token is available. Launches a coroutine on `Dispatchers.IO` to POST the token to the backend: + +1. Reads `backend_base_url` from string resources (define in `res/values/strings.xml` for each build variant). +2. Serialises a `FcmRegistrationPayload` and sends it with OkHttp. +3. Failures are swallowed with `runCatching` — token registration is best-effort. + +```kotlin +override fun onNewToken(token: String) { + serviceScope.launch { registerTokenWithBackend(token) } +} +``` + +### onMessageReceived + +Called for foreground messages (app in foreground). Shows a system notification via `NotificationCompat.Builder`. + +`NotificationChannel` creation is guarded by `Build.VERSION.SDK_INT >= Build.VERSION_CODES.O` because `NotificationChannel` was added in API 26 and `minSdk = 24`. + +Background messages (app not running or in background) are handled automatically by the FCM SDK without calling `onMessageReceived`. + +## Configuring the backend URL + +Add `backend_base_url` to `app/src/main/res/values/strings.xml`: +```xml +https://api.example.com +``` + +Override per build type in `app/src/debug/res/values/strings.xml`: +```xml +http://10.0.2.2:8080 +``` + +(`10.0.2.2` is the Android emulator's alias for the host machine's `localhost`.) + +## Testing + +Unit tests in `app/src/test/java/com/company/template/fcm/FcmRegistrationPayloadTest.kt` (JUnit 4, JVM): + +- Verifies JSON serialisation contains the correct field names and values +- Verifies the default platform is `"android"` +- Verifies the platform can be overridden +- Verifies round-trip serialisation (encode → decode → equal) + +No Android framework is needed for these tests. Run with `./gradlew test`. diff --git a/mobile/gradle/libs.versions.toml b/mobile/gradle/libs.versions.toml index 2364ad3..59a8f2f 100644 --- a/mobile/gradle/libs.versions.toml +++ b/mobile/gradle/libs.versions.toml @@ -9,6 +9,7 @@ activityCompose = "1.8.0" kotlin = "2.2.10" composeBom = "2026.02.01" sentry = "8.14.0" +firebaseBom = "33.7.0" okhttp = "4.12.0" kotlinxCoroutines = "1.10.2" kotlinxSerializationJson = "1.8.1" @@ -30,6 +31,8 @@ androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui- androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } sentry-android = { group = "io.sentry", name = "sentry-android", version.ref = "sentry" } +firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } +firebase-messaging-ktx = { group = "com.google.firebase", name = "firebase-messaging-ktx" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } diff --git a/web/docs/_index.md b/web/docs/_index.md index 8342d12..33999e6 100644 --- a/web/docs/_index.md +++ b/web/docs/_index.md @@ -12,3 +12,4 @@ The `docs` agent reads this index first to locate the right file. | Testing patterns | [testing.md](testing.md) | `vitest.config.ts`, `vitest.setup.ts`, `__tests__/page.test.tsx` | | Observability (Sentry error tracking) | [observability.md](observability.md) | `sentry.client.config.ts`, `sentry.server.config.ts`, `sentry.edge.config.ts`, `next.config.ts`, `.env.example` | | WebSocket hook (useWebSocket, reconnect, auth) | [websocket.md](websocket.md) | `lib/useWebSocket.ts`, `lib/useWebSocket.test.ts` | +| Firebase Cloud Messaging — permission, token, service worker, useFCM hook | [fcm.md](fcm.md) | `lib/fcm.ts`, `lib/useFCM.ts`, `public/firebase-messaging-sw.js`, `lib/fcm.test.ts` | diff --git a/web/docs/fcm.md b/web/docs/fcm.md new file mode 100644 index 0000000..84b4282 --- /dev/null +++ b/web/docs/fcm.md @@ -0,0 +1,96 @@ +--- +topic: fcm +last_verified: 2026-06-16 +sources: + - lib/fcm.ts + - lib/useFCM.ts + - public/firebase-messaging-sw.js + - lib/fcm.test.ts +--- + +# Firebase Cloud Messaging (FCM) — Web + +Enables browser push notifications via the Firebase JS SDK. Works in foreground (tab open) and background (tab closed, via service worker). + +## Environment variables + +Add these to `.env.local` (never commit real values): + +``` +NEXT_PUBLIC_FIREBASE_API_KEY= +NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN= +NEXT_PUBLIC_FIREBASE_PROJECT_ID= +NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET= +NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID= +NEXT_PUBLIC_FIREBASE_APP_ID= +NEXT_PUBLIC_FIREBASE_VAPID_KEY= +``` + +Obtain the web app config from **Firebase Console → Project Settings → General → Your apps**. Obtain the VAPID key from **Firebase Console → Project Settings → Cloud Messaging → Web Push certificates**. + +## lib/fcm.ts + +Pure utility module. Must only be imported in Client Components (`"use client"`). + +```ts +// Request notification permission and obtain the FCM registration token. +// Returns null if the browser does not support notifications or if the +// user denies the permission prompt. +requestPermissionAndGetToken(): Promise + +// Subscribe to foreground messages. Returns an unsubscribe function. +// Call the returned function in the component's cleanup. +onForegroundMessage(handler: (payload: MessagePayload) => void): () => void +``` + +`requestPermissionAndGetToken` registers `public/firebase-messaging-sw.js` as a service worker before calling `getToken`, so the service worker is ready before the FCM token is obtained. + +Firebase app initialisation is lazy and idempotent: `getApps().length > 0` check prevents re-initialising on hot reloads. + +## lib/useFCM.ts + +Client hook. Requests permission, gets the FCM token, and POSTs it to the backend on mount: + +```ts +useFCM({ idToken?: string; onMessage?: (payload: MessagePayload) => void }): void +``` + +- `idToken`: Firebase Auth ID token. Sent as `Authorization: Bearer ` to authenticate the backend registration request. Omit when Firebase Auth is not yet wired on the web. +- `onMessage`: called for each foreground message while the component is mounted. + +The hook re-runs (re-registers the token) whenever `idToken` changes, so switching users automatically re-registers the token under the new identity. + +Example usage in a root layout or auth-protected page: +```tsx +'use client' +import { useFCM } from '@/lib/useFCM' + +export default function NotificationProvider({ idToken }: { idToken?: string }) { + useFCM({ + idToken, + onMessage: (payload) => console.log('FCM foreground:', payload), + }) + return null +} +``` + +## public/firebase-messaging-sw.js + +Service worker for background messages (tab closed or not focused). Loaded by `requestPermissionAndGetToken` automatically. + +The service worker receives the Firebase config from the page via `postMessage` with `{ type: 'FIREBASE_CONFIG', config: { ... } }`. Send this immediately after registering the worker: + +```ts +const reg = await navigator.serviceWorker.register('/firebase-messaging-sw.js') +reg.active?.postMessage({ type: 'FIREBASE_CONFIG', config: firebaseConfig }) +``` + +## Testing + +Tests live in `lib/fcm.test.ts` (Vitest, jsdom). Both `firebase/app` and `firebase/messaging` are fully mocked with `vi.mock` + `vi.hoisted()`. + +Covered paths: +- Returns `null` when `Notification` is absent from `window` +- Returns `null` when the user denies permission +- Returns the FCM token on success (verifies `getToken` is called with the service worker registration) +- `onForegroundMessage` registers the handler and returns the unsubscribe function diff --git a/web/lib/fcm.test.ts b/web/lib/fcm.test.ts new file mode 100644 index 0000000..3b7605b --- /dev/null +++ b/web/lib/fcm.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { mockGetToken, mockOnMessage, mockGetMessaging, mockGetApps, mockInitializeApp } = + vi.hoisted(() => ({ + mockGetToken: vi.fn(), + mockOnMessage: vi.fn(() => vi.fn()), + mockGetMessaging: vi.fn(() => ({})), + mockGetApps: vi.fn(() => [] as unknown[]), + mockInitializeApp: vi.fn(() => ({ name: '[DEFAULT]' })), + })) + +vi.mock('firebase/app', () => ({ + initializeApp: mockInitializeApp, + getApps: mockGetApps, +})) + +vi.mock('firebase/messaging', () => ({ + getMessaging: mockGetMessaging, + getToken: mockGetToken, + onMessage: mockOnMessage, +})) + +import { requestPermissionAndGetToken, onForegroundMessage } from './fcm' + +beforeEach(() => { + vi.clearAllMocks() + mockGetApps.mockReturnValue([]) + mockGetMessaging.mockReturnValue({}) +}) + +describe('requestPermissionAndGetToken', () => { + it('returns null when Notification is not in window', async () => { + const saved = (globalThis as Record).Notification + delete (globalThis as Record).Notification + + const result = await requestPermissionAndGetToken() + expect(result).toBeNull() + expect(mockGetToken).not.toHaveBeenCalled() + + ;(globalThis as Record).Notification = saved + }) + + it('returns null when permission is denied', async () => { + Object.defineProperty(globalThis, 'Notification', { + value: { requestPermission: vi.fn().mockResolvedValue('denied') }, + configurable: true, + writable: true, + }) + + const result = await requestPermissionAndGetToken() + expect(result).toBeNull() + expect(mockGetToken).not.toHaveBeenCalled() + }) + + it('returns the FCM token when permission is granted', async () => { + const fakeToken = 'fcm-registration-token-abc' + const fakeRegistration = { scope: '/firebase-messaging-sw.js' } + + Object.defineProperty(globalThis, 'Notification', { + value: { requestPermission: vi.fn().mockResolvedValue('granted') }, + configurable: true, + writable: true, + }) + Object.defineProperty(globalThis, 'navigator', { + value: { serviceWorker: { register: vi.fn().mockResolvedValue(fakeRegistration) } }, + configurable: true, + writable: true, + }) + mockGetToken.mockResolvedValue(fakeToken) + + const result = await requestPermissionAndGetToken() + + expect(result).toBe(fakeToken) + expect(mockGetToken).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ serviceWorkerRegistration: fakeRegistration }), + ) + }) +}) + +describe('onForegroundMessage', () => { + it('registers the handler and returns the unsubscribe function', () => { + const handler = vi.fn() + const unsubscribe = vi.fn() + mockOnMessage.mockReturnValue(unsubscribe) + + const result = onForegroundMessage(handler) + + expect(mockOnMessage).toHaveBeenCalledWith(expect.anything(), handler) + expect(result).toBe(unsubscribe) + }) +}) diff --git a/web/lib/fcm.ts b/web/lib/fcm.ts new file mode 100644 index 0000000..619958b --- /dev/null +++ b/web/lib/fcm.ts @@ -0,0 +1,47 @@ +'use client' + +import { initializeApp, getApps, type FirebaseApp } from 'firebase/app' +import { getMessaging, getToken, onMessage, type MessagePayload } 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, +} + +function getFirebaseApp(): FirebaseApp { + if (getApps().length > 0) return getApps()[0]! + return initializeApp(firebaseConfig) +} + +/** + * Requests Notification permission, registers the service worker, and returns + * the FCM registration token. Returns null when permission is denied or the + * browser does not support notifications. + */ +export async function requestPermissionAndGetToken(): Promise { + if (typeof window === 'undefined' || !('Notification' in window)) return null + + const permission = await Notification.requestPermission() + if (permission !== 'granted') return null + + const registration = await navigator.serviceWorker.register('/firebase-messaging-sw.js') + const messaging = getMessaging(getFirebaseApp()) + + return getToken(messaging, { + vapidKey: process.env.NEXT_PUBLIC_FIREBASE_VAPID_KEY, + serviceWorkerRegistration: registration, + }) +} + +/** + * Subscribes to foreground FCM messages. Returns an unsubscribe function. + * Call this once per component lifecycle; call the returned function on cleanup. + */ +export function onForegroundMessage(handler: (payload: MessagePayload) => void): () => void { + const messaging = getMessaging(getFirebaseApp()) + return onMessage(messaging, handler) +} diff --git a/web/lib/useFCM.ts b/web/lib/useFCM.ts new file mode 100644 index 0000000..56b4f42 --- /dev/null +++ b/web/lib/useFCM.ts @@ -0,0 +1,44 @@ +'use client' + +import { useEffect } from 'react' +import { requestPermissionAndGetToken, onForegroundMessage } from '@/lib/fcm' +import type { MessagePayload } from 'firebase/messaging' + +/** + * Requests push notification permission, obtains the FCM token, and registers + * it with the backend. Optionally subscribes to foreground messages. + * + * idToken: Firebase Auth ID token to authenticate the registration request. + * onMessage: called for each foreground push message received. + */ +export function useFCM({ + idToken, + onMessage, +}: { + idToken?: string + onMessage?: (payload: MessagePayload) => void +} = {}): void { + useEffect(() => { + let cancelled = false + + requestPermissionAndGetToken().then(async (token) => { + if (!token || cancelled) return + await fetch('/api/v1/fcm/register', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(idToken ? { Authorization: `Bearer ${idToken}` } : {}), + }, + body: JSON.stringify({ token, platform: 'web' }), + }) + }) + + if (!onMessage) return + const unsub = onForegroundMessage(onMessage) + return () => { + cancelled = true + unsub() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [idToken]) +} diff --git a/web/package.json b/web/package.json index 0603bd8..1efc28f 100644 --- a/web/package.json +++ b/web/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@sentry/nextjs": "^10.57.0", + "firebase": "^12.14.0", "next": "16.2.9", "react": "19.2.4", "react-dom": "19.2.4" diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index f34851f..687bb1a 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@sentry/nextjs': specifier: ^10.57.0 version: 10.57.0(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(webpack@5.107.2) + firebase: + specifier: ^12.14.0 + version: 12.14.0 next: specifier: 16.2.9 version: 16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -255,6 +258,225 @@ packages: '@noble/hashes': optional: true + '@firebase/ai@2.13.0': + resolution: {integrity: sha512-nJJDQKqjAcbkZdZGT/5WTVLrGZ+pYhWbwKC90nNzmvtoRTtnOJaNS34fhKSHQeB9SALgD2kxuWT5I4AkytdZ/Q==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@firebase/app': 0.x + '@firebase/app-types': 0.x + + '@firebase/analytics-compat@0.2.28': + resolution: {integrity: sha512-lIAlqUUbBu93FJMlQfslryQtBwwzdzvp23ePC6FNgymXk6Ook5v4Uvc0vdutvoIeqmyA3LfP0ZeRFK8+11kOOQ==} + peerDependencies: + '@firebase/app-compat': 0.x + + '@firebase/analytics-types@0.8.4': + resolution: {integrity: sha512-zQ+XTgkwH6CY/eUSHJRP7e4LxM30RCxlCmob5sy2axs25GE3Ny0XdgpDscMTHHQIGqWkxPXad4w2Mw9sCgT8zQ==} + + '@firebase/analytics@0.10.22': + resolution: {integrity: sha512-8BSaq/QRGU1+xyi8L2PTLTJU7MH9aMA72RQdIxrbhWFauOZY9OXo8f2YDN/972xA8d588tlnNVEQ2Mo69pT9Ow==} + peerDependencies: + '@firebase/app': 0.x + + '@firebase/app-check-compat@0.4.4': + resolution: {integrity: sha512-9iP0MvmaVagulNXmrca96U3tqNAI3j98wsC1z7rj62nnOTajlrHM//jjB9VoHqRw6/islMskp6RsKnM7vhLDqA==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@firebase/app-compat': 0.x + + '@firebase/app-check-interop-types@0.3.4': + resolution: {integrity: sha512-zz3i6e13B8BfWiLy8MABtTh8aGIACgKbf9UVnyHcWs+yQzJXgQcl8A46b0zfaiJHdQ+niF0ouAfcpuf+3LMPQg==} + + '@firebase/app-check-types@0.5.4': + resolution: {integrity: sha512-xV7JsIyzVr15aA7f3Pi0rB9gdBuVubs89FGA8VkRYA4g0l78poADgdfrScgf7NndSg9mm7cR7PJyY0+t22KaGw==} + + '@firebase/app-check@0.11.4': + resolution: {integrity: sha512-G8EsbVJV9gSfoibx0dNoNOUrvr+PkL7J//+W/BST/oUassimkZeq9bjj3bKkB0pn4og5GMQ9qs7FefwP00kkgg==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@firebase/app': 0.x + + '@firebase/app-compat@0.5.13': + resolution: {integrity: sha512-pn3FvXwUR34kWPccDQfCKsNZcM2wD1OS+J1jeEgzM1ZNXoxR2NaF6e5DjDuRrnTwR6LN2XQQt0IqE6yKmgpCQg==} + engines: {node: '>=20.0.0'} + + '@firebase/app-types@0.9.5': + resolution: {integrity: sha512-YevqTjvo7Iujsa9Dwowmd6dSoElhzmD63ZSrq6bzjvQ6POjYgNjOFHLmNIgJs48eNO093NCERibuFnxbfOvU7A==} + + '@firebase/app@0.14.13': + resolution: {integrity: sha512-H89Jeyp31+EZk9GPu6vaeL9mEmoXgM3nASB7UPBYYS/lqAks21mO1BU1dF8NbsVTL6tgGZkGUtiGJgxtDiwHkw==} + engines: {node: '>=20.0.0'} + + '@firebase/auth-compat@0.6.7': + resolution: {integrity: sha512-XgKnOgY1Siq7gylAmLkYtHAlRxNeWEAspH+nO3gJZJnfHqoTHbr9UjJ3nHNFALYXV5CfpQlyPROyB2ztySBHBQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@firebase/app-compat': 0.x + + '@firebase/auth-interop-types@0.2.5': + resolution: {integrity: sha512-1Li/YuBDBAXcKv7BzY4U28gontUmAaw53sYiqbaVOMCFb2lFKK/c3CGMUWqtwe7+TXrl3poWnTCL5umYBg85Eg==} + + '@firebase/auth-types@0.13.1': + resolution: {integrity: sha512-0c1Mnid0uMDfGJHeUS4zfvBa4/CedJXotGy/n/NZJnBjwiJawt0ZYU+wH2VAVLiRCEfG2ncCkAX3yd1/2nrB7g==} + peerDependencies: + '@firebase/app-types': 0.x + '@firebase/util': 1.x + + '@firebase/auth@1.13.2': + resolution: {integrity: sha512-B4w0iS7MxRg28oIh2fJFTE6cM0lYdBrW19eHpc42jqEcloUjlYyVrpPqZvqA4+v9KFEVSKEs2SfWyta7hbzkJQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@firebase/app': 0.x + '@react-native-async-storage/async-storage': ^2.2.0 || ^3.0.0 + peerDependenciesMeta: + '@react-native-async-storage/async-storage': + optional: true + + '@firebase/component@0.7.3': + resolution: {integrity: sha512-wFofIaa2879ogD/WvkjYXJxRmfnL0scen6ORgaC3na1FNOR9ASIUANQdhqQcmWu/h77/pVHY7ch5flewa5Bcew==} + engines: {node: '>=20.0.0'} + + '@firebase/data-connect@0.7.1': + resolution: {integrity: sha512-2LbUU8mmSA63HknxQMmWHjpzuNLBKflvVwQc2tpoVKg0biWleNEJX031ELks0vzFs+dDjOUkCJR72RP6mQHFOg==} + peerDependencies: + '@firebase/app': 0.x + + '@firebase/database-compat@2.1.4': + resolution: {integrity: sha512-3pK35F1MAgmqFJQlf2nhQl44vtAXQO1uaCaQOEUI9kCRtLFqi7N+QRKR7lFZPg+xIZIyubgxQaxY69YgfZRZWg==} + engines: {node: '>=20.0.0'} + + '@firebase/database-types@1.0.20': + resolution: {integrity: sha512-kegbOk/w8iU64pr0q6k2ItyNGjnQBMHFhwS7ohdWI4W+pc0/zhhdGXTdFj6X1oxItRjPoYOsSQmERgBkn/ihxw==} + + '@firebase/database@1.1.3': + resolution: {integrity: sha512-XwWCa+E4TvNGpGwXrycLRNfdogADwFcvuhyow6wDWma9W54roaQIhe+4PM0KiLsIftBdSCGI7OKCXrdSRHbIhw==} + engines: {node: '>=20.0.0'} + + '@firebase/firestore-compat@0.4.10': + resolution: {integrity: sha512-yMP3FADDjikdrQv4YmvL4EkIny6Hw+N+a2O5T40rlHiniyMpRPxgYkKiFOvMZnsqKLqBVnKqCAElC0pa/IZtdw==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@firebase/app-compat': 0.x + + '@firebase/firestore-types@3.0.4': + resolution: {integrity: sha512-jGn+JSS4X9zZsrfu7Yw66v5YRdOLD1oyQh4USR0xWl4CUqV/DA6bNIXRPpxH/cUl3iVTNiP6MN7g+EL42A4qfA==} + peerDependencies: + '@firebase/app-types': 0.x + '@firebase/util': 1.x + + '@firebase/firestore@4.15.0': + resolution: {integrity: sha512-Fj9osqYkz2Rqr7kW3/A8BRd8CyJ7yA5K8YjhihRdyJWbL+FsELVcR6DpoCplrp1IyU+xeGgTubo1UOySXpY+EA==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@firebase/app': 0.x + + '@firebase/functions-compat@0.4.5': + resolution: {integrity: sha512-10qlUXGY25G5/1g9UihqksPp2po+ZqSE7LEizsrdUP7vrTmkysXxGSZCDyojSEp6mQe/ecRDdDDI+z4XRdb4wQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@firebase/app-compat': 0.x + + '@firebase/functions-types@0.6.4': + resolution: {integrity: sha512-zV6kgqtduR4rUAdC/ilS7kmb93XD7bEZoJDlVBZqlOw2uGGGCNBQBuleww2rr0Ulr3L9o2TDjumEt68/l1f9DQ==} + + '@firebase/functions@0.13.5': + resolution: {integrity: sha512-bWCx713f4kE/uFV7gdFOLBS7lDoiZj48MRkbAqe35gkXcCeWF4QjRNO07Jhmve7EJIoQOBczL29y2r8VRuN1kw==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@firebase/app': 0.x + + '@firebase/installations-compat@0.2.22': + resolution: {integrity: sha512-C/zpAuTP5S9OgKSPvXRupw3hoY/JZSlA1wFjD/Sb7LIQE0FNbcMdO8Y4KXVEkjVzma/DDDDIAzxEXqKMAzc88w==} + peerDependencies: + '@firebase/app-compat': 0.x + + '@firebase/installations-types@0.5.4': + resolution: {integrity: sha512-U2eFapdHwjb43Vx9o+Pmj4dFfvcHEK1IirEFLqMtWrTHvmdrS3gBpBD1kmJk/9HjsOtoHZxJ2Paoe79e+L1ZPg==} + peerDependencies: + '@firebase/app-types': 0.x + + '@firebase/installations@0.6.22': + resolution: {integrity: sha512-ef6nn3GGQTdReCfotRMG77PJZu8CqEbiK5pEoBnM0gTu/Z9v0i/az2p3HABsa/1beQmmyh1OsOjf7P5+pgwdZw==} + peerDependencies: + '@firebase/app': 0.x + + '@firebase/logger@0.5.1': + resolution: {integrity: sha512-vZKLsqE1ABOy8OjQiE7cUTFn4gvaqlk88yp8N94Pk/sDpq61YqZGqmVFZTvOyflTwuYFcWirBdYGoJgbDaXKYQ==} + engines: {node: '>=20.0.0'} + + '@firebase/messaging-compat@0.2.27': + resolution: {integrity: sha512-JNOiu1PPgdHzEPEtoFiNxQuu0x9bm4bfETSQCpGfcTlgWkhlSK7uh7nlsjC10TQLUNgYetLmuutaYTh8aeYLVA==} + peerDependencies: + '@firebase/app-compat': 0.x + + '@firebase/messaging-interop-types@0.2.5': + resolution: {integrity: sha512-tUEKnaAP2Y/MNIqgnriPpV6e5l13Vs/+p2yrd6NGlncPJT9O3a8muYZtdnWe+IJ4fgKLHJVC79n/asxk/N5Msw==} + + '@firebase/messaging@0.13.0': + resolution: {integrity: sha512-GZoo0uGRvEbszo83xcgbjJp4FpkmBEr4l8Z4hi8gl+P1Spn/MTK3HapanMzSX4yUHuTEiF5hasWRxOaz+o5sxQ==} + peerDependencies: + '@firebase/app': 0.x + + '@firebase/performance-compat@0.2.25': + resolution: {integrity: sha512-q6NjTXpIPoFuUmCmMN/maCdTgzT6aExs9xZo+PxfVLj6uLVGvpyAD6XWjmcrb7jChsFBYbq7E5dyNDF7Zhy9kA==} + peerDependencies: + '@firebase/app-compat': 0.x + + '@firebase/performance-types@0.2.4': + resolution: {integrity: sha512-kJSEk7b0uhpcPRyL4SQ/GPujLqk52XNKcXlnsKDbWGAb9vugcLvOU3u6zfEdwd+d8hWJb5S5ZizV1JFFI0nkKg==} + + '@firebase/performance@0.7.12': + resolution: {integrity: sha512-fe7nV8teUU3OBHlMUZ9Lw4gLhCW2k4m5Uc3pfWGV+fl8uwJQBGp9Q3lqsJ+HSrFu3Q2pJyLAgrClPGSKyDeYgQ==} + peerDependencies: + '@firebase/app': 0.x + + '@firebase/remote-config-compat@0.2.25': + resolution: {integrity: sha512-FnA5S4IxFJAAFrCnYzWlO0FCaizlYdqhe42ygFMA+wE/mUP+w36iXzHyKj1OO1A+2gyMFjeRHyg8HhkJ6c5vRA==} + peerDependencies: + '@firebase/app-compat': 0.x + + '@firebase/remote-config-types@0.5.1': + resolution: {integrity: sha512-cX/1LT6KQwkXzck2eSzeKnuvXZCyr8qaPpDcikoJs7jmI+oBOXixpDLeDtWj1U6GNMkIoXrEDNoyT2Ypcyp5/A==} + + '@firebase/remote-config@0.8.4': + resolution: {integrity: sha512-lslywR5lGvHWTu4z/MPoYs3UwS3CKdeY+ELXY87087VsOpBpkD+9Orra23tA9GW683arPTDOM3CM6eKmtiOO3g==} + peerDependencies: + '@firebase/app': 0.x + + '@firebase/storage-compat@0.4.3': + resolution: {integrity: sha512-gruVqjtUGX8tEoeNbaWXZm0Zfcfcb7fvmDmBxV8yPAbWvExRnZYLO2+qw9idxNE7BvPXt5csyjSYHy//dAizxw==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@firebase/app-compat': 0.x + + '@firebase/storage-types@0.8.4': + resolution: {integrity: sha512-BT7cwxJOx8SWwlQfrlC+bD/Sk3Cw+1odCi8UZNFNWTVZoPsBnA5W+mqtZzVnvsdJpXCFGSGQ7R7vOR6dtM/BRA==} + peerDependencies: + '@firebase/app-types': 0.x + '@firebase/util': 1.x + + '@firebase/storage@0.14.3': + resolution: {integrity: sha512-YX4/YL6P6/fufSSeGnVhjWddcIXbFq2cWIhMKFTZo1E/Rtcl2mJj/BYUQTwJfcE1Tl8un1FOya4L05jcSLN/Eg==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@firebase/app': 0.x + + '@firebase/util@1.15.1': + resolution: {integrity: sha512-LUdM4Wg7YM9Pq/49nGYySJA0CSQEKnGffFzWV8+6gXN7mGxn+FL1IqvFbuZUtAQcfZgHYDwCE1wwlK7rB7gl2g==} + engines: {node: '>=20.0.0'} + + '@firebase/webchannel-wrapper@1.0.6': + resolution: {integrity: sha512-Vr/Mqu79dMwGRAyGbJ4uN4+BtXB3/mRTdzetD1daWNeG8QaWuzhhbG77GltO5c0yYmYls8i250iX73624GJd7Q==} + + '@grpc/grpc-js@1.9.16': + resolution: {integrity: sha512-wE4Ut/olIzfKqp631XrG+wbF0v1vWFN4YL9FyXC2LJiG33DsV7PLzURjrCvY/6je2ntdRkeLpPDluzSRGaVltQ==} + 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 + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -546,6 +768,33 @@ packages: '@oxc-project/types@0.133.0': resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} + '@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.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + + '@protobufjs/eventemitter@1.1.1': + resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==} + + '@protobufjs/fetch@1.1.1': + resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@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.1': + resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==} + '@rolldown/binding-android-arm64@1.0.3': resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1534,6 +1783,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'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1646,6 +1899,9 @@ packages: electron-to-chromium@1.5.372: resolution: {integrity: sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} @@ -1865,6 +2121,10 @@ packages: fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + 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'} @@ -1886,6 +2146,9 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} + firebase@12.14.0: + resolution: {integrity: sha512-aEZ/lniDR1hOCYpx/x/V8Nrrqq9pepKDNkqP/4WGZFC69gTv6F59Z4/54W/SUP4L/hFlrRNmWj35aweQq+IHow==} + flat-cache@4.0.1: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} @@ -1920,6 +2183,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-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -2006,10 +2273,16 @@ packages: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + http-parser-js@0.5.10: + resolution: {integrity: sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==} + https-proxy-agent@5.0.1: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} + idb@7.1.1: + resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -2085,6 +2358,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'} @@ -2307,9 +2584,15 @@ 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.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + 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 @@ -2543,6 +2826,10 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + protobufjs@7.6.4: + resolution: {integrity: sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==} + engines: {node: '>=12.0.0'} + proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} @@ -2580,6 +2867,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'} @@ -2621,6 +2912,9 @@ packages: resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} 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'} @@ -2720,6 +3014,10 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + string.prototype.includes@2.0.1: resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} engines: {node: '>= 0.4'} @@ -2743,6 +3041,10 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} @@ -3038,6 +3340,9 @@ packages: resolution: {integrity: sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==} engines: {node: '>=10.13.0'} + web-vitals@4.2.4: + resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==} + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -3059,6 +3364,14 @@ packages: webpack-cli: optional: true + websocket-driver@0.7.5: + resolution: {integrity: sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==} + engines: {node: '>=0.8.0'} + + websocket-extensions@0.1.4: + resolution: {integrity: sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==} + engines: {node: '>=0.8.0'} + whatwg-mimetype@5.0.0: resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} engines: {node: '>=20'} @@ -3100,6 +3413,10 @@ 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'} + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -3107,9 +3424,21 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + 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'} @@ -3348,6 +3677,338 @@ snapshots: '@exodus/bytes@1.15.1': {} + '@firebase/ai@2.13.0(@firebase/app-types@0.9.5)(@firebase/app@0.14.13)': + dependencies: + '@firebase/app': 0.14.13 + '@firebase/app-check-interop-types': 0.3.4 + '@firebase/app-types': 0.9.5 + '@firebase/component': 0.7.3 + '@firebase/logger': 0.5.1 + '@firebase/util': 1.15.1 + tslib: 2.8.1 + + '@firebase/analytics-compat@0.2.28(@firebase/app-compat@0.5.13)(@firebase/app@0.14.13)': + dependencies: + '@firebase/analytics': 0.10.22(@firebase/app@0.14.13) + '@firebase/analytics-types': 0.8.4 + '@firebase/app-compat': 0.5.13 + '@firebase/component': 0.7.3 + '@firebase/util': 1.15.1 + tslib: 2.8.1 + transitivePeerDependencies: + - '@firebase/app' + + '@firebase/analytics-types@0.8.4': {} + + '@firebase/analytics@0.10.22(@firebase/app@0.14.13)': + dependencies: + '@firebase/app': 0.14.13 + '@firebase/component': 0.7.3 + '@firebase/installations': 0.6.22(@firebase/app@0.14.13) + '@firebase/logger': 0.5.1 + '@firebase/util': 1.15.1 + tslib: 2.8.1 + + '@firebase/app-check-compat@0.4.4(@firebase/app-compat@0.5.13)(@firebase/app@0.14.13)': + dependencies: + '@firebase/app-check': 0.11.4(@firebase/app@0.14.13) + '@firebase/app-check-types': 0.5.4 + '@firebase/app-compat': 0.5.13 + '@firebase/component': 0.7.3 + '@firebase/logger': 0.5.1 + '@firebase/util': 1.15.1 + tslib: 2.8.1 + transitivePeerDependencies: + - '@firebase/app' + + '@firebase/app-check-interop-types@0.3.4': {} + + '@firebase/app-check-types@0.5.4': {} + + '@firebase/app-check@0.11.4(@firebase/app@0.14.13)': + dependencies: + '@firebase/app': 0.14.13 + '@firebase/component': 0.7.3 + '@firebase/logger': 0.5.1 + '@firebase/util': 1.15.1 + tslib: 2.8.1 + + '@firebase/app-compat@0.5.13': + dependencies: + '@firebase/app': 0.14.13 + '@firebase/component': 0.7.3 + '@firebase/logger': 0.5.1 + '@firebase/util': 1.15.1 + tslib: 2.8.1 + + '@firebase/app-types@0.9.5': + dependencies: + '@firebase/logger': 0.5.1 + + '@firebase/app@0.14.13': + dependencies: + '@firebase/component': 0.7.3 + '@firebase/logger': 0.5.1 + '@firebase/util': 1.15.1 + idb: 7.1.1 + tslib: 2.8.1 + + '@firebase/auth-compat@0.6.7(@firebase/app-compat@0.5.13)(@firebase/app-types@0.9.5)(@firebase/app@0.14.13)': + dependencies: + '@firebase/app-compat': 0.5.13 + '@firebase/auth': 1.13.2(@firebase/app@0.14.13) + '@firebase/auth-types': 0.13.1(@firebase/app-types@0.9.5)(@firebase/util@1.15.1) + '@firebase/component': 0.7.3 + '@firebase/util': 1.15.1 + tslib: 2.8.1 + transitivePeerDependencies: + - '@firebase/app' + - '@firebase/app-types' + - '@react-native-async-storage/async-storage' + + '@firebase/auth-interop-types@0.2.5': {} + + '@firebase/auth-types@0.13.1(@firebase/app-types@0.9.5)(@firebase/util@1.15.1)': + dependencies: + '@firebase/app-types': 0.9.5 + '@firebase/util': 1.15.1 + + '@firebase/auth@1.13.2(@firebase/app@0.14.13)': + dependencies: + '@firebase/app': 0.14.13 + '@firebase/component': 0.7.3 + '@firebase/logger': 0.5.1 + '@firebase/util': 1.15.1 + tslib: 2.8.1 + + '@firebase/component@0.7.3': + dependencies: + '@firebase/util': 1.15.1 + tslib: 2.8.1 + + '@firebase/data-connect@0.7.1(@firebase/app@0.14.13)': + dependencies: + '@firebase/app': 0.14.13 + '@firebase/auth-interop-types': 0.2.5 + '@firebase/component': 0.7.3 + '@firebase/logger': 0.5.1 + '@firebase/util': 1.15.1 + tslib: 2.8.1 + + '@firebase/database-compat@2.1.4': + dependencies: + '@firebase/component': 0.7.3 + '@firebase/database': 1.1.3 + '@firebase/database-types': 1.0.20 + '@firebase/logger': 0.5.1 + '@firebase/util': 1.15.1 + tslib: 2.8.1 + + '@firebase/database-types@1.0.20': + dependencies: + '@firebase/app-types': 0.9.5 + '@firebase/util': 1.15.1 + + '@firebase/database@1.1.3': + dependencies: + '@firebase/app-check-interop-types': 0.3.4 + '@firebase/auth-interop-types': 0.2.5 + '@firebase/component': 0.7.3 + '@firebase/logger': 0.5.1 + '@firebase/util': 1.15.1 + faye-websocket: 0.11.4 + tslib: 2.8.1 + + '@firebase/firestore-compat@0.4.10(@firebase/app-compat@0.5.13)(@firebase/app-types@0.9.5)(@firebase/app@0.14.13)': + dependencies: + '@firebase/app-compat': 0.5.13 + '@firebase/component': 0.7.3 + '@firebase/firestore': 4.15.0(@firebase/app@0.14.13) + '@firebase/firestore-types': 3.0.4(@firebase/app-types@0.9.5)(@firebase/util@1.15.1) + '@firebase/util': 1.15.1 + tslib: 2.8.1 + transitivePeerDependencies: + - '@firebase/app' + - '@firebase/app-types' + + '@firebase/firestore-types@3.0.4(@firebase/app-types@0.9.5)(@firebase/util@1.15.1)': + dependencies: + '@firebase/app-types': 0.9.5 + '@firebase/util': 1.15.1 + + '@firebase/firestore@4.15.0(@firebase/app@0.14.13)': + dependencies: + '@firebase/app': 0.14.13 + '@firebase/component': 0.7.3 + '@firebase/logger': 0.5.1 + '@firebase/util': 1.15.1 + '@firebase/webchannel-wrapper': 1.0.6 + '@grpc/grpc-js': 1.9.16 + '@grpc/proto-loader': 0.7.15 + tslib: 2.8.1 + + '@firebase/functions-compat@0.4.5(@firebase/app-compat@0.5.13)(@firebase/app@0.14.13)': + dependencies: + '@firebase/app-compat': 0.5.13 + '@firebase/component': 0.7.3 + '@firebase/functions': 0.13.5(@firebase/app@0.14.13) + '@firebase/functions-types': 0.6.4 + '@firebase/util': 1.15.1 + tslib: 2.8.1 + transitivePeerDependencies: + - '@firebase/app' + + '@firebase/functions-types@0.6.4': {} + + '@firebase/functions@0.13.5(@firebase/app@0.14.13)': + dependencies: + '@firebase/app': 0.14.13 + '@firebase/app-check-interop-types': 0.3.4 + '@firebase/auth-interop-types': 0.2.5 + '@firebase/component': 0.7.3 + '@firebase/messaging-interop-types': 0.2.5 + '@firebase/util': 1.15.1 + tslib: 2.8.1 + + '@firebase/installations-compat@0.2.22(@firebase/app-compat@0.5.13)(@firebase/app-types@0.9.5)(@firebase/app@0.14.13)': + dependencies: + '@firebase/app-compat': 0.5.13 + '@firebase/component': 0.7.3 + '@firebase/installations': 0.6.22(@firebase/app@0.14.13) + '@firebase/installations-types': 0.5.4(@firebase/app-types@0.9.5) + '@firebase/util': 1.15.1 + tslib: 2.8.1 + transitivePeerDependencies: + - '@firebase/app' + - '@firebase/app-types' + + '@firebase/installations-types@0.5.4(@firebase/app-types@0.9.5)': + dependencies: + '@firebase/app-types': 0.9.5 + + '@firebase/installations@0.6.22(@firebase/app@0.14.13)': + dependencies: + '@firebase/app': 0.14.13 + '@firebase/component': 0.7.3 + '@firebase/util': 1.15.1 + idb: 7.1.1 + tslib: 2.8.1 + + '@firebase/logger@0.5.1': + dependencies: + tslib: 2.8.1 + + '@firebase/messaging-compat@0.2.27(@firebase/app-compat@0.5.13)(@firebase/app@0.14.13)': + dependencies: + '@firebase/app-compat': 0.5.13 + '@firebase/component': 0.7.3 + '@firebase/messaging': 0.13.0(@firebase/app@0.14.13) + '@firebase/util': 1.15.1 + tslib: 2.8.1 + transitivePeerDependencies: + - '@firebase/app' + + '@firebase/messaging-interop-types@0.2.5': {} + + '@firebase/messaging@0.13.0(@firebase/app@0.14.13)': + dependencies: + '@firebase/app': 0.14.13 + '@firebase/component': 0.7.3 + '@firebase/installations': 0.6.22(@firebase/app@0.14.13) + '@firebase/messaging-interop-types': 0.2.5 + '@firebase/util': 1.15.1 + idb: 7.1.1 + tslib: 2.8.1 + + '@firebase/performance-compat@0.2.25(@firebase/app-compat@0.5.13)(@firebase/app@0.14.13)': + dependencies: + '@firebase/app-compat': 0.5.13 + '@firebase/component': 0.7.3 + '@firebase/logger': 0.5.1 + '@firebase/performance': 0.7.12(@firebase/app@0.14.13) + '@firebase/performance-types': 0.2.4 + '@firebase/util': 1.15.1 + tslib: 2.8.1 + transitivePeerDependencies: + - '@firebase/app' + + '@firebase/performance-types@0.2.4': {} + + '@firebase/performance@0.7.12(@firebase/app@0.14.13)': + dependencies: + '@firebase/app': 0.14.13 + '@firebase/component': 0.7.3 + '@firebase/installations': 0.6.22(@firebase/app@0.14.13) + '@firebase/logger': 0.5.1 + '@firebase/util': 1.15.1 + tslib: 2.8.1 + web-vitals: 4.2.4 + + '@firebase/remote-config-compat@0.2.25(@firebase/app-compat@0.5.13)(@firebase/app@0.14.13)': + dependencies: + '@firebase/app-compat': 0.5.13 + '@firebase/component': 0.7.3 + '@firebase/logger': 0.5.1 + '@firebase/remote-config': 0.8.4(@firebase/app@0.14.13) + '@firebase/remote-config-types': 0.5.1 + '@firebase/util': 1.15.1 + tslib: 2.8.1 + transitivePeerDependencies: + - '@firebase/app' + + '@firebase/remote-config-types@0.5.1': {} + + '@firebase/remote-config@0.8.4(@firebase/app@0.14.13)': + dependencies: + '@firebase/app': 0.14.13 + '@firebase/component': 0.7.3 + '@firebase/installations': 0.6.22(@firebase/app@0.14.13) + '@firebase/logger': 0.5.1 + '@firebase/util': 1.15.1 + tslib: 2.8.1 + + '@firebase/storage-compat@0.4.3(@firebase/app-compat@0.5.13)(@firebase/app-types@0.9.5)(@firebase/app@0.14.13)': + dependencies: + '@firebase/app-compat': 0.5.13 + '@firebase/component': 0.7.3 + '@firebase/storage': 0.14.3(@firebase/app@0.14.13) + '@firebase/storage-types': 0.8.4(@firebase/app-types@0.9.5)(@firebase/util@1.15.1) + '@firebase/util': 1.15.1 + tslib: 2.8.1 + transitivePeerDependencies: + - '@firebase/app' + - '@firebase/app-types' + + '@firebase/storage-types@0.8.4(@firebase/app-types@0.9.5)(@firebase/util@1.15.1)': + dependencies: + '@firebase/app-types': 0.9.5 + '@firebase/util': 1.15.1 + + '@firebase/storage@0.14.3(@firebase/app@0.14.13)': + dependencies: + '@firebase/app': 0.14.13 + '@firebase/component': 0.7.3 + '@firebase/util': 1.15.1 + tslib: 2.8.1 + + '@firebase/util@1.15.1': + dependencies: + tslib: 2.8.1 + + '@firebase/webchannel-wrapper@1.0.6': {} + + '@grpc/grpc-js@1.9.16': + dependencies: + '@grpc/proto-loader': 0.7.15 + '@types/node': 20.19.43 + + '@grpc/proto-loader@0.7.15': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.6.4 + yargs: 17.7.2 + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -3573,6 +4234,26 @@ snapshots: '@oxc-project/types@0.133.0': {} + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.5': {} + + '@protobufjs/eventemitter@1.1.1': {} + + '@protobufjs/fetch@1.1.1': + dependencies: + '@protobufjs/aspromise': 1.1.2 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.1': {} + '@rolldown/binding-android-arm64@1.0.3': optional: true @@ -4524,6 +5205,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 + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -4626,6 +5313,8 @@ snapshots: electron-to-chromium@1.5.372: {} + emoji-regex@8.0.0: {} + emoji-regex@9.2.2: {} enhanced-resolve@5.21.6: @@ -4989,6 +5678,10 @@ snapshots: dependencies: reusify: 1.1.0 + faye-websocket@0.11.4: + dependencies: + websocket-driver: 0.7.5 + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -5006,6 +5699,39 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 + firebase@12.14.0: + dependencies: + '@firebase/ai': 2.13.0(@firebase/app-types@0.9.5)(@firebase/app@0.14.13) + '@firebase/analytics': 0.10.22(@firebase/app@0.14.13) + '@firebase/analytics-compat': 0.2.28(@firebase/app-compat@0.5.13)(@firebase/app@0.14.13) + '@firebase/app': 0.14.13 + '@firebase/app-check': 0.11.4(@firebase/app@0.14.13) + '@firebase/app-check-compat': 0.4.4(@firebase/app-compat@0.5.13)(@firebase/app@0.14.13) + '@firebase/app-compat': 0.5.13 + '@firebase/app-types': 0.9.5 + '@firebase/auth': 1.13.2(@firebase/app@0.14.13) + '@firebase/auth-compat': 0.6.7(@firebase/app-compat@0.5.13)(@firebase/app-types@0.9.5)(@firebase/app@0.14.13) + '@firebase/data-connect': 0.7.1(@firebase/app@0.14.13) + '@firebase/database': 1.1.3 + '@firebase/database-compat': 2.1.4 + '@firebase/firestore': 4.15.0(@firebase/app@0.14.13) + '@firebase/firestore-compat': 0.4.10(@firebase/app-compat@0.5.13)(@firebase/app-types@0.9.5)(@firebase/app@0.14.13) + '@firebase/functions': 0.13.5(@firebase/app@0.14.13) + '@firebase/functions-compat': 0.4.5(@firebase/app-compat@0.5.13)(@firebase/app@0.14.13) + '@firebase/installations': 0.6.22(@firebase/app@0.14.13) + '@firebase/installations-compat': 0.2.22(@firebase/app-compat@0.5.13)(@firebase/app-types@0.9.5)(@firebase/app@0.14.13) + '@firebase/messaging': 0.13.0(@firebase/app@0.14.13) + '@firebase/messaging-compat': 0.2.27(@firebase/app-compat@0.5.13)(@firebase/app@0.14.13) + '@firebase/performance': 0.7.12(@firebase/app@0.14.13) + '@firebase/performance-compat': 0.2.25(@firebase/app-compat@0.5.13)(@firebase/app@0.14.13) + '@firebase/remote-config': 0.8.4(@firebase/app@0.14.13) + '@firebase/remote-config-compat': 0.2.25(@firebase/app-compat@0.5.13)(@firebase/app@0.14.13) + '@firebase/storage': 0.14.3(@firebase/app@0.14.13) + '@firebase/storage-compat': 0.4.3(@firebase/app-compat@0.5.13)(@firebase/app-types@0.9.5)(@firebase/app@0.14.13) + '@firebase/util': 1.15.1 + transitivePeerDependencies: + - '@react-native-async-storage/async-storage' + flat-cache@4.0.1: dependencies: flatted: 3.4.2 @@ -5040,6 +5766,8 @@ snapshots: gensync@1.0.0-beta.2: {} + get-caller-file@2.0.5: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -5131,6 +5859,8 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + http-parser-js@0.5.10: {} + https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 @@ -5138,6 +5868,8 @@ snapshots: transitivePeerDependencies: - supports-color + idb@7.1.1: {} + ignore@5.3.2: {} ignore@7.0.5: {} @@ -5218,6 +5950,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 @@ -5432,8 +6166,12 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.camelcase@4.3.0: {} + lodash.merge@4.6.2: {} + long@5.3.2: {} + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -5653,6 +6391,20 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + protobufjs@7.6.4: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 + '@protobufjs/float': 1.0.2 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.1 + '@types/node': 20.19.43 + long: 5.3.2 + proxy-from-env@1.1.0: {} punycode@2.3.1: {} @@ -5695,6 +6447,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: @@ -5783,6 +6537,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 @@ -5925,6 +6681,12 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + string.prototype.includes@2.0.1: dependencies: call-bind: 1.0.9 @@ -5976,6 +6738,10 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.2 + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + strip-bom@3.0.0: {} strip-indent@3.0.0: @@ -6216,6 +6982,8 @@ snapshots: dependencies: graceful-fs: 4.2.11 + web-vitals@4.2.4: {} + webidl-conversions@3.0.1: {} webidl-conversions@8.0.1: {} @@ -6261,6 +7029,14 @@ snapshots: - postcss - uglify-js + websocket-driver@0.7.5: + dependencies: + http-parser-js: 0.5.10 + safe-buffer: 5.2.1 + websocket-extensions: 0.1.4 + + websocket-extensions@0.1.4: {} + whatwg-mimetype@5.0.0: {} whatwg-url@16.0.1: @@ -6328,12 +7104,32 @@ 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 + xml-name-validator@5.0.0: {} xmlchars@2.2.0: {} + y18n@5.0.8: {} + yallist@3.1.1: {} + 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: {} zod-validation-error@4.0.2(zod@4.4.3): diff --git a/web/public/firebase-messaging-sw.js b/web/public/firebase-messaging-sw.js new file mode 100644 index 0000000..2eb978f --- /dev/null +++ b/web/public/firebase-messaging-sw.js @@ -0,0 +1,17 @@ +importScripts('https://www.gstatic.com/firebasejs/11.0.0/firebase-app-compat.js') +importScripts('https://www.gstatic.com/firebasejs/11.0.0/firebase-messaging-compat.js') + +// Firebase config is injected by the page before registering this worker, +// via postMessage, or you can hardcode NEXT_PUBLIC_* values here at build time. +// For development, populate these from your .env.local file. +self.addEventListener('message', (event) => { + if (event.data?.type === 'FIREBASE_CONFIG') { + firebase.initializeApp(event.data.config) + firebase.messaging() + } +}) + +// Background message handler — called when the app is not in the foreground. +self.addEventListener('push', () => { + // firebase-messaging-compat handles push events automatically once initialised. +})