Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/docs/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
11 changes: 7 additions & 4 deletions backend/docs/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
---
Expand Down Expand Up @@ -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`.
174 changes: 174 additions & 0 deletions backend/docs/fcm.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
---
topic: fcm
last_verified: 2026-06-16

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix last_verified to a non-future date.

Line 3 is 2026-06-16, which is in the future relative to June 15, 2026; please set it to the actual verification date.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/docs/fcm.md` at line 3, The last_verified field in
backend/docs/fcm.md on line 3 contains a future date of 2026-06-16 instead of
the actual verification date. Update the last_verified value to reflect the
actual date when the documentation was last verified, ensuring it is not a
future date relative to today.

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": "<fcm-registration-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": "<fcm-registration-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`.
Loading
Loading