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: 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**
|
||||
|
|
|
|||
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", 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)
|
||||
|
||||
|
|
|
|||
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