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:
Tiago Yamamoto 2025-12-27 11:24:27 -03:00
parent 9ee9f6855c
commit 63023b922f
7 changed files with 699 additions and 2 deletions

View file

@ -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**

View 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
}

View 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"`
}

View file

@ -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)

View 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
}

View 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(&notif.ID, &notif.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
}

View 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';