feat(notifications): implementar sistema de notificações e FCM
- Migration 017: tabelas notifications e fcm_tokens
- Models: Notification, FCMToken
- NotificationService: CRUD, push notifications helper
- FCMService: Firebase Cloud Messaging integration
- NotificationHandler: endpoints REST
- Rotas autenticadas: /api/v1/notifications/*
Endpoints:
- GET /api/v1/notifications
- GET /api/v1/notifications/unread-count
- PUT /api/v1/notifications/read-all
- PUT /api/v1/notifications/{id}/read
- DELETE /api/v1/notifications/{id}
- POST /api/v1/notifications/fcm-token
- DELETE /api/v1/notifications/fcm-token
This commit is contained in:
parent
9ee9f6855c
commit
63023b922f
7 changed files with 699 additions and 2 deletions
|
|
@ -114,9 +114,10 @@
|
||||||
[x] Frontend: NotificationContext e NotificationDropdown
|
[x] Frontend: NotificationContext e NotificationDropdown
|
||||||
[x] Frontend: Badge de notificações no header
|
[x] Frontend: Badge de notificações no header
|
||||||
[x] Frontend: Lista de notificações (mock data)
|
[x] Frontend: Lista de notificações (mock data)
|
||||||
[ ] Backend: Tabela de notificações
|
[x] Backend: Tabela de notificações (migration 017)
|
||||||
[ ] Backend: FCM (Firebase Cloud Messaging) integration
|
[x] Backend: FCM (Firebase Cloud Messaging) integration
|
||||||
[x] Backend: Envio de email transacional (Mock)
|
[x] Backend: Envio de email transacional (Mock)
|
||||||
|
[ ] Backend: Notificação por email para empresa (integração real)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 7. **Busca e Filtros Avançados**
|
### 7. **Busca e Filtros Avançados**
|
||||||
|
|
|
||||||
237
backend/internal/handlers/notification_handler.go
Normal file
237
backend/internal/handlers/notification_handler.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
59
backend/internal/models/notification.go
Normal file
59
backend/internal/models/notification.go
Normal file
|
|
@ -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"`
|
||||||
|
}
|
||||||
|
|
@ -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/stats", activityLogHandler.GetActivityLogStats)
|
||||||
mux.HandleFunc("GET /api/v1/activity-logs", activityLogHandler.GetActivityLogs)
|
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
|
// Swagger Route - available at /docs
|
||||||
mux.HandleFunc("/docs/", httpSwagger.WrapHandler)
|
mux.HandleFunc("/docs/", httpSwagger.WrapHandler)
|
||||||
|
|
||||||
|
|
|
||||||
106
backend/internal/services/fcm_service.go
Normal file
106
backend/internal/services/fcm_service.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
228
backend/internal/services/notification_service.go
Normal file
228
backend/internal/services/notification_service.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
54
backend/migrations/017_create_notifications_table.sql
Normal file
54
backend/migrations/017_create_notifications_table.sql
Normal file
|
|
@ -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';
|
||||||
Loading…
Reference in a new issue