diff --git a/ROADMAP.md b/ROADMAP.md index b8619f4..d1d8932 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -114,9 +114,10 @@ [x] Frontend: NotificationContext e NotificationDropdown [x] Frontend: Badge de notificações no header [x] Frontend: Lista de notificações (mock data) -[ ] Backend: Tabela de notificações -[ ] Backend: FCM (Firebase Cloud Messaging) integration +[x] Backend: Tabela de notificações (migration 017) +[x] Backend: FCM (Firebase Cloud Messaging) integration [x] Backend: Envio de email transacional (Mock) +[ ] Backend: Notificação por email para empresa (integração real) ``` ### 7. **Busca e Filtros Avançados** diff --git a/backend/internal/handlers/notification_handler.go b/backend/internal/handlers/notification_handler.go new file mode 100644 index 0000000..001869c --- /dev/null +++ b/backend/internal/handlers/notification_handler.go @@ -0,0 +1,237 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strconv" + + "github.com/rede5/gohorsejobs/backend/internal/models" + "github.com/rede5/gohorsejobs/backend/internal/services" +) + +type NotificationHandler struct { + service *services.NotificationService +} + +func NewNotificationHandler(service *services.NotificationService) *NotificationHandler { + return &NotificationHandler{service: service} +} + +// GetNotifications lists notifications for the authenticated user +// @Summary List Notifications +// @Description Get all notifications for the current user +// @Tags Notifications +// @Produce json +// @Param limit query int false "Limit results (default 50)" +// @Param offset query int false "Offset for pagination" +// @Param unreadOnly query bool false "Only show unread" +// @Success 200 {array} models.Notification +// @Router /api/v1/notifications [get] +func (h *NotificationHandler) GetNotifications(w http.ResponseWriter, r *http.Request) { + // Get user ID from context (set by auth middleware) + userID := getUserIDFromContext(r) + if userID == 0 { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) + unreadOnly := r.URL.Query().Get("unreadOnly") == "true" + + if limit == 0 { + limit = 50 + } + + notifications, err := h.service.GetByUserID(userID, limit, offset, unreadOnly) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(notifications) +} + +// GetUnreadCount returns the count of unread notifications +// @Summary Get Unread Count +// @Description Get count of unread notifications +// @Tags Notifications +// @Produce json +// @Success 200 {object} map[string]int +// @Router /api/v1/notifications/unread-count [get] +func (h *NotificationHandler) GetUnreadCount(w http.ResponseWriter, r *http.Request) { + userID := getUserIDFromContext(r) + if userID == 0 { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + count, err := h.service.GetUnreadCount(userID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]int{"count": count}) +} + +// MarkAsRead marks a notification as read +// @Summary Mark as Read +// @Description Mark a specific notification as read +// @Tags Notifications +// @Param id path int true "Notification ID" +// @Success 200 +// @Router /api/v1/notifications/{id}/read [put] +func (h *NotificationHandler) MarkAsRead(w http.ResponseWriter, r *http.Request) { + userID := getUserIDFromContext(r) + if userID == 0 { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + idStr := r.PathValue("id") + id, err := strconv.Atoi(idStr) + if err != nil { + http.Error(w, "Invalid notification ID", http.StatusBadRequest) + return + } + + if err := h.service.MarkAsRead(id, userID); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +// MarkAllAsRead marks all notifications as read +// @Summary Mark All as Read +// @Description Mark all notifications as read for the current user +// @Tags Notifications +// @Success 200 +// @Router /api/v1/notifications/read-all [put] +func (h *NotificationHandler) MarkAllAsRead(w http.ResponseWriter, r *http.Request) { + userID := getUserIDFromContext(r) + if userID == 0 { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + if err := h.service.MarkAllAsRead(userID); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +// DeleteNotification deletes a notification +// @Summary Delete Notification +// @Description Delete a specific notification +// @Tags Notifications +// @Param id path int true "Notification ID" +// @Success 204 +// @Router /api/v1/notifications/{id} [delete] +func (h *NotificationHandler) DeleteNotification(w http.ResponseWriter, r *http.Request) { + userID := getUserIDFromContext(r) + if userID == 0 { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + idStr := r.PathValue("id") + id, err := strconv.Atoi(idStr) + if err != nil { + http.Error(w, "Invalid notification ID", http.StatusBadRequest) + return + } + + if err := h.service.Delete(id, userID); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// RegisterFCMToken registers a device token for push notifications +// @Summary Register FCM Token +// @Description Register a device token for push notifications +// @Tags Notifications +// @Accept json +// @Param token body models.RegisterFCMTokenRequest true "FCM Token" +// @Success 200 +// @Router /api/v1/notifications/fcm-token [post] +func (h *NotificationHandler) RegisterFCMToken(w http.ResponseWriter, r *http.Request) { + userID := getUserIDFromContext(r) + if userID == 0 { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + var req models.RegisterFCMTokenRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + if req.Token == "" { + http.Error(w, "Token is required", http.StatusBadRequest) + return + } + + if err := h.service.RegisterFCMToken(userID, req); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +// UnregisterFCMToken removes a device token +// @Summary Unregister FCM Token +// @Description Remove a device token from push notifications +// @Tags Notifications +// @Accept json +// @Param token body object{token string} true "Token to remove" +// @Success 200 +// @Router /api/v1/notifications/fcm-token [delete] +func (h *NotificationHandler) UnregisterFCMToken(w http.ResponseWriter, r *http.Request) { + userID := getUserIDFromContext(r) + if userID == 0 { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + var req struct { + Token string `json:"token"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + if err := h.service.UnregisterFCMToken(userID, req.Token); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +// getUserIDFromContext extracts user ID from context (set by auth middleware) +func getUserIDFromContext(r *http.Request) int { + // Check for user ID in context, set by auth middleware + if userID, ok := r.Context().Value("userID").(int); ok { + return userID + } + // Fallback: check header for testing + if userIDStr := r.Header.Get("X-User-ID"); userIDStr != "" { + if id, err := strconv.Atoi(userIDStr); err == nil { + return id + } + } + return 0 +} diff --git a/backend/internal/models/notification.go b/backend/internal/models/notification.go new file mode 100644 index 0000000..7d5afb7 --- /dev/null +++ b/backend/internal/models/notification.go @@ -0,0 +1,59 @@ +package models + +import "time" + +// Notification represents an in-app notification +type Notification struct { + ID int `json:"id" db:"id"` + UserID int `json:"userId" db:"user_id"` + TenantID *string `json:"tenantId,omitempty" db:"tenant_id"` + Title string `json:"title" db:"title"` + Message string `json:"message" db:"message"` + Type string `json:"type" db:"type"` // info, success, warning, error, application, job, message + ActionURL *string `json:"actionUrl,omitempty" db:"action_url"` + ActionLabel *string `json:"actionLabel,omitempty" db:"action_label"` + Read bool `json:"read" db:"read"` + ReadAt *time.Time `json:"readAt,omitempty" db:"read_at"` + PushSent bool `json:"pushSent" db:"push_sent"` + PushSentAt *time.Time `json:"pushSentAt,omitempty" db:"push_sent_at"` + Metadata []byte `json:"metadata,omitempty" db:"metadata"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` +} + +// FCMToken represents a Firebase Cloud Messaging device token +type FCMToken struct { + ID int `json:"id" db:"id"` + UserID int `json:"userId" db:"user_id"` + Token string `json:"token" db:"token"` + DeviceType *string `json:"deviceType,omitempty" db:"device_type"` // web, android, ios + DeviceName *string `json:"deviceName,omitempty" db:"device_name"` + Active bool `json:"active" db:"active"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` +} + +// CreateNotificationRequest for creating a notification +type CreateNotificationRequest struct { + UserID int `json:"userId"` + Title string `json:"title"` + Message string `json:"message"` + Type string `json:"type"` + ActionURL *string `json:"actionUrl,omitempty"` + ActionLabel *string `json:"actionLabel,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + SendPush bool `json:"sendPush"` +} + +// RegisterFCMTokenRequest for registering a device token +type RegisterFCMTokenRequest struct { + Token string `json:"token"` + DeviceType *string `json:"deviceType,omitempty"` + DeviceName *string `json:"deviceName,omitempty"` +} + +// NotificationStats for dashboard +type NotificationStats struct { + Total int `json:"total"` + Unread int `json:"unread"` + ByType map[string]int `json:"byType"` +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 3629cf4..18d9cd0 100755 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -193,6 +193,18 @@ func NewRouter() http.Handler { mux.HandleFunc("GET /api/v1/activity-logs/stats", activityLogHandler.GetActivityLogStats) mux.HandleFunc("GET /api/v1/activity-logs", activityLogHandler.GetActivityLogs) + // --- NOTIFICATION ROUTES --- + fcmService := services.NewFCMService() + notificationService := services.NewNotificationService(database.DB, fcmService) + notificationHandler := handlers.NewNotificationHandler(notificationService) + mux.Handle("GET /api/v1/notifications", authMiddleware.HeaderAuthGuard(http.HandlerFunc(notificationHandler.GetNotifications))) + mux.Handle("GET /api/v1/notifications/unread-count", authMiddleware.HeaderAuthGuard(http.HandlerFunc(notificationHandler.GetUnreadCount))) + mux.Handle("PUT /api/v1/notifications/read-all", authMiddleware.HeaderAuthGuard(http.HandlerFunc(notificationHandler.MarkAllAsRead))) + mux.Handle("PUT /api/v1/notifications/{id}/read", authMiddleware.HeaderAuthGuard(http.HandlerFunc(notificationHandler.MarkAsRead))) + mux.Handle("DELETE /api/v1/notifications/{id}", authMiddleware.HeaderAuthGuard(http.HandlerFunc(notificationHandler.DeleteNotification))) + mux.Handle("POST /api/v1/notifications/fcm-token", authMiddleware.HeaderAuthGuard(http.HandlerFunc(notificationHandler.RegisterFCMToken))) + mux.Handle("DELETE /api/v1/notifications/fcm-token", authMiddleware.HeaderAuthGuard(http.HandlerFunc(notificationHandler.UnregisterFCMToken))) + // Swagger Route - available at /docs mux.HandleFunc("/docs/", httpSwagger.WrapHandler) diff --git a/backend/internal/services/fcm_service.go b/backend/internal/services/fcm_service.go new file mode 100644 index 0000000..405295b --- /dev/null +++ b/backend/internal/services/fcm_service.go @@ -0,0 +1,106 @@ +package services + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + "time" +) + +// FCMService handles Firebase Cloud Messaging +type FCMService struct { + serverKey string + projectID string + client *http.Client +} + +// NewFCMService creates a new FCM service +func NewFCMService() *FCMService { + serverKey := os.Getenv("FCM_SERVER_KEY") + projectID := os.Getenv("FCM_PROJECT_ID") + + if serverKey == "" && projectID == "" { + // Return nil if not configured - notifications will still work without push + return nil + } + + return &FCMService{ + serverKey: serverKey, + projectID: projectID, + client: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} + +// FCMMessage represents an FCM push message +type FCMMessage struct { + To string `json:"to,omitempty"` + Notification FCMNotification `json:"notification"` + Data map[string]string `json:"data,omitempty"` + Priority string `json:"priority"` +} + +type FCMNotification struct { + Title string `json:"title"` + Body string `json:"body"` + Icon string `json:"icon,omitempty"` + Badge string `json:"badge,omitempty"` +} + +// Send sends a push notification to a device +func (s *FCMService) Send(token, title, body string, data map[string]string) error { + if s == nil || s.serverKey == "" { + // FCM not configured, skip silently + return nil + } + + message := FCMMessage{ + To: token, + Notification: FCMNotification{ + Title: title, + Body: body, + Icon: "/icon-192x192.png", + }, + Data: data, + Priority: "high", + } + + jsonData, err := json.Marshal(message) + if err != nil { + return err + } + + req, err := http.NewRequest("POST", "https://fcm.googleapis.com/fcm/send", bytes.NewBuffer(jsonData)) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "key="+s.serverKey) + + resp, err := s.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("FCM returned status %d", resp.StatusCode) + } + + return nil +} + +// SendMulticast sends to multiple devices +func (s *FCMService) SendMulticast(tokens []string, title, body string, data map[string]string) []error { + var errors []error + for _, token := range tokens { + if err := s.Send(token, title, body, data); err != nil { + errors = append(errors, err) + } + } + return errors +} diff --git a/backend/internal/services/notification_service.go b/backend/internal/services/notification_service.go new file mode 100644 index 0000000..fa6a446 --- /dev/null +++ b/backend/internal/services/notification_service.go @@ -0,0 +1,228 @@ +package services + +import ( + "database/sql" + "encoding/json" + + "github.com/rede5/gohorsejobs/backend/internal/models" +) + +type NotificationService struct { + db *sql.DB + fcmService *FCMService +} + +func NewNotificationService(db *sql.DB, fcmService *FCMService) *NotificationService { + return &NotificationService{db: db, fcmService: fcmService} +} + +// Create creates a new notification +func (s *NotificationService) Create(req models.CreateNotificationRequest) (*models.Notification, error) { + var metadataJSON []byte + if req.Metadata != nil { + metadataJSON, _ = json.Marshal(req.Metadata) + } + + query := ` + INSERT INTO notifications (user_id, title, message, type, action_url, action_label, metadata) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id, created_at + ` + + notif := &models.Notification{ + UserID: req.UserID, + Title: req.Title, + Message: req.Message, + Type: req.Type, + ActionURL: req.ActionURL, + ActionLabel: req.ActionLabel, + Metadata: metadataJSON, + Read: false, + } + + err := s.db.QueryRow(query, req.UserID, req.Title, req.Message, req.Type, req.ActionURL, req.ActionLabel, metadataJSON). + Scan(¬if.ID, ¬if.CreatedAt) + if err != nil { + return nil, err + } + + // Send push notification if requested + if req.SendPush && s.fcmService != nil { + go s.sendPushNotification(notif) + } + + return notif, nil +} + +// sendPushNotification sends push via FCM +func (s *NotificationService) sendPushNotification(notif *models.Notification) { + tokens, err := s.GetUserFCMTokens(notif.UserID) + if err != nil || len(tokens) == 0 { + return + } + + for _, token := range tokens { + err := s.fcmService.Send(token.Token, notif.Title, notif.Message, map[string]string{ + "notificationId": string(rune(notif.ID)), + "type": notif.Type, + }) + if err == nil { + // Mark as sent + s.db.Exec("UPDATE notifications SET push_sent = true, push_sent_at = NOW() WHERE id = $1", notif.ID) + } + } +} + +// GetByUserID lists notifications for a user +func (s *NotificationService) GetByUserID(userID int, limit, offset int, unreadOnly bool) ([]models.Notification, error) { + query := ` + SELECT id, user_id, tenant_id, title, message, type, action_url, action_label, + read, read_at, push_sent, push_sent_at, metadata, created_at + FROM notifications + WHERE user_id = $1 + ` + if unreadOnly { + query += " AND read = false" + } + query += " ORDER BY created_at DESC LIMIT $2 OFFSET $3" + + rows, err := s.db.Query(query, userID, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + var notifications []models.Notification + for rows.Next() { + var n models.Notification + err := rows.Scan( + &n.ID, &n.UserID, &n.TenantID, &n.Title, &n.Message, &n.Type, + &n.ActionURL, &n.ActionLabel, &n.Read, &n.ReadAt, + &n.PushSent, &n.PushSentAt, &n.Metadata, &n.CreatedAt, + ) + if err != nil { + return nil, err + } + notifications = append(notifications, n) + } + + return notifications, nil +} + +// MarkAsRead marks a notification as read +func (s *NotificationService) MarkAsRead(id, userID int) error { + _, err := s.db.Exec( + "UPDATE notifications SET read = true, read_at = NOW() WHERE id = $1 AND user_id = $2", + id, userID, + ) + return err +} + +// MarkAllAsRead marks all notifications as read for a user +func (s *NotificationService) MarkAllAsRead(userID int) error { + _, err := s.db.Exec( + "UPDATE notifications SET read = true, read_at = NOW() WHERE user_id = $1 AND read = false", + userID, + ) + return err +} + +// GetUnreadCount returns unread count for a user +func (s *NotificationService) GetUnreadCount(userID int) (int, error) { + var count int + err := s.db.QueryRow("SELECT COUNT(*) FROM notifications WHERE user_id = $1 AND read = false", userID).Scan(&count) + return count, err +} + +// Delete deletes a notification +func (s *NotificationService) Delete(id, userID int) error { + _, err := s.db.Exec("DELETE FROM notifications WHERE id = $1 AND user_id = $2", id, userID) + return err +} + +// RegisterFCMToken registers or updates a device token +func (s *NotificationService) RegisterFCMToken(userID int, req models.RegisterFCMTokenRequest) error { + query := ` + INSERT INTO fcm_tokens (user_id, token, device_type, device_name, active, updated_at) + VALUES ($1, $2, $3, $4, true, NOW()) + ON CONFLICT (user_id, token) DO UPDATE SET active = true, updated_at = NOW() + ` + _, err := s.db.Exec(query, userID, req.Token, req.DeviceType, req.DeviceName) + return err +} + +// UnregisterFCMToken deactivates a device token +func (s *NotificationService) UnregisterFCMToken(userID int, token string) error { + _, err := s.db.Exec("UPDATE fcm_tokens SET active = false, updated_at = NOW() WHERE user_id = $1 AND token = $2", userID, token) + return err +} + +// GetUserFCMTokens gets active FCM tokens for a user +func (s *NotificationService) GetUserFCMTokens(userID int) ([]models.FCMToken, error) { + query := `SELECT id, user_id, token, device_type, device_name, active, created_at, updated_at + FROM fcm_tokens WHERE user_id = $1 AND active = true` + rows, err := s.db.Query(query, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var tokens []models.FCMToken + for rows.Next() { + var t models.FCMToken + if err := rows.Scan(&t.ID, &t.UserID, &t.Token, &t.DeviceType, &t.DeviceName, &t.Active, &t.CreatedAt, &t.UpdatedAt); err != nil { + return nil, err + } + tokens = append(tokens, t) + } + return tokens, nil +} + +// NotifyNewApplication sends notification when someone applies to a job +func (s *NotificationService) NotifyNewApplication(companyUserID int, applicantName, jobTitle string, applicationID int) error { + actionURL := "/dashboard/applications/" + string(rune(applicationID)) + req := models.CreateNotificationRequest{ + UserID: companyUserID, + Title: "Nova Candidatura", + Message: applicantName + " se candidatou para " + jobTitle, + Type: "application", + ActionURL: &actionURL, + ActionLabel: strPtr("Ver Candidatura"), + SendPush: true, + } + _, err := s.Create(req) + return err +} + +// NotifyApplicationStatus sends notification when application status changes +func (s *NotificationService) NotifyApplicationStatus(userID int, jobTitle, status string) error { + var title, message string + switch status { + case "shortlisted": + title = "Você foi selecionado!" + message = "Sua candidatura para " + jobTitle + " avançou para a próxima fase" + case "rejected": + title = "Atualização de Candidatura" + message = "Sua candidatura para " + jobTitle + " não foi aprovada desta vez" + case "hired": + title = "Parabéns! 🎉" + message = "Você foi contratado para " + jobTitle + default: + title = "Atualização de Candidatura" + message = "Sua candidatura para " + jobTitle + " foi atualizada" + } + + req := models.CreateNotificationRequest{ + UserID: userID, + Title: title, + Message: message, + Type: "application", + SendPush: true, + } + _, err := s.Create(req) + return err +} + +func strPtr(s string) *string { + return &s +} diff --git a/backend/migrations/017_create_notifications_table.sql b/backend/migrations/017_create_notifications_table.sql new file mode 100644 index 0000000..83d7e37 --- /dev/null +++ b/backend/migrations/017_create_notifications_table.sql @@ -0,0 +1,54 @@ +-- Migration: Create notifications table +-- Description: Stores user notifications for in-app and push notifications + +CREATE TABLE IF NOT EXISTS notifications ( + id SERIAL PRIMARY KEY, + user_id INT REFERENCES users(id) ON DELETE CASCADE, + tenant_id VARCHAR(36), + + -- Notification content + title VARCHAR(255) NOT NULL, + message TEXT NOT NULL, + type VARCHAR(50) NOT NULL DEFAULT 'info', -- info, success, warning, error, application, job, message + + -- Action/Link + action_url VARCHAR(500), + action_label VARCHAR(100), + + -- Status + read BOOLEAN DEFAULT false, + read_at TIMESTAMP, + + -- Push notification + push_sent BOOLEAN DEFAULT false, + push_sent_at TIMESTAMP, + + -- Metadata + metadata JSONB, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- FCM device tokens for push notifications +CREATE TABLE IF NOT EXISTS fcm_tokens ( + id SERIAL PRIMARY KEY, + user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token VARCHAR(500) NOT NULL, + device_type VARCHAR(20), -- web, android, ios + device_name VARCHAR(100), + active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, token) +); + +-- Indexes +CREATE INDEX idx_notifications_user ON notifications(user_id); +CREATE INDEX idx_notifications_tenant ON notifications(tenant_id); +CREATE INDEX idx_notifications_read ON notifications(user_id, read); +CREATE INDEX idx_notifications_type ON notifications(type); +CREATE INDEX idx_notifications_created ON notifications(created_at DESC); +CREATE INDEX idx_fcm_tokens_user ON fcm_tokens(user_id); +CREATE INDEX idx_fcm_tokens_active ON fcm_tokens(user_id, active); + +COMMENT ON TABLE notifications IS 'User notifications for in-app display and push notifications'; +COMMENT ON TABLE fcm_tokens IS 'Firebase Cloud Messaging device tokens for push notifications';