Self-Hosted Push Notifications Specification
Part 2: Backend Implementation (Go)
Version: 1.0
Last Updated: October 2025
Prerequisites: Part 1: Architecture & Setup
Author: Bunty9
License: MIT (Free to use and adapt)
Table of Contents
- Dependencies
- GORM Models
- Type Definitions
- Push Service Implementation
- HTTP Controllers
- Route Configuration
- Middleware
- Utilities
- Main Application
- Testing
Dependencies
Install Required Packages
# Navigate to backend directory
cd backend
# Initialize Go module (if not already done)
go mod init github.com/yourusername/your-app
# Install core dependencies
go get github.com/gin-gonic/gin # HTTP framework
go get gorm.io/gorm # ORM
go get gorm.io/driver/postgres # PostgreSQL driver
go get github.com/SherClockHolmes/webpush-go # Web Push Protocol
go get github.com/google/uuid # UUID generation
go get github.com/joho/godotenv # Environment variables
go get github.com/golang-jwt/jwt/v5 # JWT authentication
# Optional but recommended
go get github.com/gin-contrib/cors # CORS middleware
go get golang.org/x/time/rate # Rate limiting
go get github.com/sirupsen/logrus # Structured logging
go.mod Example
module github.com/yourusername/your-app
go 1.21
require (
github.com/SherClockHolmes/webpush-go v1.3.0
github.com/gin-contrib/cors v1.5.0
github.com/gin-gonic/gin v1.9.1
github.com/golang-jwt/jwt/v5 v5.2.0
github.com/google/uuid v1.5.0
github.com/joho/godotenv v1.5.1
github.com/sirupsen/logrus v1.9.3
golang.org/x/time v0.5.0
gorm.io/driver/postgres v1.5.4
gorm.io/gorm v1.25.5
)
GORM Models
models/push_subscription.go
package models
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// PushSubscription represents a push notification subscription for a device
type PushSubscription struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
// User/Admin Association (mutually exclusive)
UserID *uuid.UUID `gorm:"type:uuid;index" json:"user_id,omitempty"`
AdminID *uuid.UUID `gorm:"type:uuid;index" json:"admin_id,omitempty"`
// Push Subscription Data (from browser)
Endpoint string `gorm:"type:text;not null;uniqueIndex" json:"endpoint"`
P256dhKey string `gorm:"type:text;not null" json:"p256dh_key"`
AuthKey string `gorm:"type:text;not null" json:"auth_key"`
// Device Metadata
DeviceID string `gorm:"type:varchar(255);index" json:"device_id,omitempty"`
DeviceName string `gorm:"type:varchar(255)" json:"device_name,omitempty"`
DeviceType string `gorm:"type:varchar(50)" json:"device_type,omitempty"` // mobile, desktop, tablet
Browser string `gorm:"type:varchar(50)" json:"browser,omitempty"` // Chrome, Firefox, Safari
OS string `gorm:"type:varchar(50)" json:"os,omitempty"` // Windows, macOS, Linux
UserAgent string `gorm:"type:text" json:"user_agent,omitempty"`
// Health & Lifecycle
IsActive bool `gorm:"not null;default:true;index" json:"is_active"`
FailureCount int `gorm:"not null;default:0" json:"failure_count"`
LastUsedAt *time.Time `gorm:"type:timestamp;index" json:"last_used_at,omitempty"`
// Timestamps
CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at"`
UpdatedAt time.Time `gorm:"not null;default:now()" json:"updated_at"`
}
// TableName specifies the table name for GORM
func (PushSubscription) TableName() string {
return "push_subscriptions"
}
// BeforeCreate hook to generate UUID if not set
func (ps *PushSubscription) BeforeCreate(tx *gorm.DB) error {
if ps.ID == uuid.Nil {
ps.ID = uuid.New()
}
return nil
}
// IsUser returns true if this subscription belongs to a user (not admin)
func (ps *PushSubscription) IsUser() bool {
return ps.UserID != nil && ps.AdminID == nil
}
// IsAdmin returns true if this subscription belongs to an admin
func (ps *PushSubscription) IsAdmin() bool {
return ps.AdminID != nil && ps.UserID == nil
}
// GetOwnerID returns the UUID of the owner (user or admin)
func (ps *PushSubscription) GetOwnerID() uuid.UUID {
if ps.UserID != nil {
return *ps.UserID
}
if ps.AdminID != nil {
return *ps.AdminID
}
return uuid.Nil
}
models/push_notification_log.go
package models
import (
"time"
"github.com/google/uuid"
"gorm.io/datatypes"
"gorm.io/gorm"
)
// PushNotificationLog represents an audit log for push notification attempts
type PushNotificationLog struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
// Relations
SubscriptionID *uuid.UUID `gorm:"type:uuid;index" json:"subscription_id,omitempty"`
UserID *uuid.UUID `gorm:"type:uuid;index" json:"user_id,omitempty"`
AdminID *uuid.UUID `gorm:"type:uuid;index" json:"admin_id,omitempty"`
// Notification Content
Title string `gorm:"type:varchar(255)" json:"title"`
Body string `gorm:"type:text" json:"body"`
Payload datatypes.JSON `gorm:"type:jsonb" json:"payload,omitempty"`
// Delivery Status
Status string `gorm:"type:varchar(50);not null;index" json:"status"` // sent, failed, expired
ErrorMessage string `gorm:"type:text" json:"error_message,omitempty"`
HTTPStatusCode int `gorm:"type:integer" json:"http_status_code,omitempty"`
// Timestamps
SentAt time.Time `gorm:"not null;default:now();index" json:"sent_at"`
CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at"`
}
// TableName specifies the table name for GORM
func (PushNotificationLog) TableName() string {
return "push_notification_logs"
}
// BeforeCreate hook to generate UUID if not set
func (pnl *PushNotificationLog) BeforeCreate(tx *gorm.DB) error {
if pnl.ID == uuid.Nil {
pnl.ID = uuid.New()
}
return nil
}
Type Definitions
types/push_types.go
package types
import (
"github.com/google/uuid"
)
// ==================== Request Types ====================
// SubscribePushRequest represents the request to subscribe to push notifications
type SubscribePushRequest struct {
Endpoint string `json:"endpoint" binding:"required"`
Keys SubscriptionKeys `json:"keys" binding:"required"`
UserAgent string `json:"userAgent"`
}
// SubscriptionKeys contains the encryption keys from the browser
type SubscriptionKeys struct {
P256dh string `json:"p256dh" binding:"required"`
Auth string `json:"auth" binding:"required"`
}
// UnsubscribePushRequest represents the request to unsubscribe
type UnsubscribePushRequest struct {
Endpoint string `json:"endpoint" binding:"required"`
}
// SendPushRequest represents a request to send a push notification
type SendPushRequest struct {
UserID *uuid.UUID `json:"userId,omitempty"`
AdminID *uuid.UUID `json:"adminId,omitempty"`
DeviceID *string `json:"deviceId,omitempty"`
Payload PushPayload `json:"payload" binding:"required"`
}
// ==================== Response Types ====================
// SubscribePushResponse represents the response after subscribing
type SubscribePushResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
SubscriptionID uuid.UUID `json:"subscriptionId"`
DeviceID string `json:"deviceId"`
DeviceName string `json:"deviceName"`
}
// UnsubscribePushResponse represents the response after unsubscribing
type UnsubscribePushResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
}
// SendPushResponse represents the response after sending a notification
type SendPushResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
DevicesSent int `json:"devicesSent"`
DevicesFailed int `json:"devicesFailed"`
}
// GetDevicesResponse represents the list of user devices
type GetDevicesResponse struct {
Success bool `json:"success"`
Count int `json:"count"`
Devices []DeviceInfo `json:"devices"`
}
// DeviceInfo represents device information
type DeviceInfo struct {
ID uuid.UUID `json:"id"`
DeviceID string `json:"deviceId"`
DeviceName string `json:"deviceName"`
Browser string `json:"browser"`
OS string `json:"os"`
DeviceType string `json:"deviceType"`
IsActive bool `json:"isActive"`
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
// ==================== Payload Types ====================
// PushPayload represents the notification payload sent to browsers
type PushPayload struct {
Title string `json:"title" binding:"required"`
Body string `json:"body" binding:"required"`
Icon string `json:"icon,omitempty"`
Badge string `json:"badge,omitempty"`
Image string `json:"image,omitempty"`
Tag string `json:"tag,omitempty"` // For notification grouping/replacement
URL string `json:"url,omitempty"` // Where to navigate on click
Data map[string]interface{} `json:"data,omitempty"` // Custom data
// Advanced Options
RequireInteraction bool `json:"requireInteraction,omitempty"`
Silent bool `json:"silent,omitempty"`
Vibrate []int `json:"vibrate,omitempty"`
Actions []Action `json:"actions,omitempty"`
}
// Action represents a notification action button
type Action struct {
Action string `json:"action"`
Title string `json:"title"`
Icon string `json:"icon,omitempty"`
}
// ==================== Internal Types ====================
// DeviceMetadata contains parsed device information from user agent
type DeviceMetadata struct {
DeviceName string
Browser string
OS string
DeviceType string
}
Push Service Implementation
services/push_service.go
This is the core service that handles all push notification operations.
package services
import (
"encoding/json"
"errors"
"fmt"
"log"
"os"
"strings"
"time"
"github.com/SherClockHolmes/webpush-go"
"github.com/google/uuid"
"gorm.io/gorm"
"your-app/models"
"your-app/types"
"your-app/utils"
)
// PushService handles all push notification operations
type PushService struct {
db *gorm.DB
vapidPublicKey string
vapidPrivateKey string
vapidSubject string
}
// NewPushService creates a new PushService instance
func NewPushService(db *gorm.DB) *PushService {
return &PushService{
db: db,
vapidPublicKey: os.Getenv("VAPID_PUBLIC_KEY"),
vapidPrivateKey: os.Getenv("VAPID_PRIVATE_KEY"),
vapidSubject: os.Getenv("VAPID_SUBJECT"),
}
}
// ==================== SUBSCRIPTION MANAGEMENT ====================
// Subscribe creates or updates a push subscription
func (ps *PushService) Subscribe(userID *uuid.UUID, adminID *uuid.UUID, req types.SubscribePushRequest) (*types.SubscribePushResponse, error) {
// Validate that either userID or adminID is provided (not both, not neither)
if (userID == nil && adminID == nil) || (userID != nil && adminID != nil) {
return nil, errors.New("must provide either userID or adminID, not both")
}
// Validate endpoint
if !strings.HasPrefix(req.Endpoint, "https://") {
return nil, errors.New("invalid endpoint: must use HTTPS")
}
// Parse device metadata from user agent
deviceMetadata := utils.ParseUserAgent(req.UserAgent)
// Check if subscription already exists (by endpoint)
var existingSubscription models.PushSubscription
err := ps.db.Where("endpoint = ?", req.Endpoint).First(&existingSubscription).Error
if err == nil {
// Update existing subscription
existingSubscription.P256dhKey = req.Keys.P256dh
existingSubscription.AuthKey = req.Keys.Auth
existingSubscription.UserAgent = req.UserAgent
existingSubscription.DeviceName = deviceMetadata.DeviceName
existingSubscription.Browser = deviceMetadata.Browser
existingSubscription.OS = deviceMetadata.OS
existingSubscription.DeviceType = deviceMetadata.DeviceType
existingSubscription.IsActive = true
existingSubscription.FailureCount = 0
existingSubscription.UpdatedAt = time.Now()
if err := ps.db.Save(&existingSubscription).Error; err != nil {
return nil, fmt.Errorf("failed to update subscription: %w", err)
}
return &types.SubscribePushResponse{
Success: true,
Message: "Subscription updated successfully",
SubscriptionID: existingSubscription.ID,
DeviceID: existingSubscription.DeviceID,
DeviceName: existingSubscription.DeviceName,
}, nil
}
// Check device limit (5 devices per user/admin)
var count int64
query := ps.db.Model(&models.PushSubscription{}).Where("is_active = ?", true)
if userID != nil {
query = query.Where("user_id = ?", userID)
} else {
query = query.Where("admin_id = ?", adminID)
}
query.Count(&count)
if count >= 5 {
return nil, errors.New("device limit reached (maximum 5 devices)")
}
// Create new subscription
subscription := models.PushSubscription{
UserID: userID,
AdminID: adminID,
Endpoint: req.Endpoint,
P256dhKey: req.Keys.P256dh,
AuthKey: req.Keys.Auth,
DeviceID: uuid.New().String(), // Generate unique device ID
DeviceName: deviceMetadata.DeviceName,
Browser: deviceMetadata.Browser,
OS: deviceMetadata.OS,
DeviceType: deviceMetadata.DeviceType,
UserAgent: req.UserAgent,
IsActive: true,
}
if err := ps.db.Create(&subscription).Error; err != nil {
return nil, fmt.Errorf("failed to create subscription: %w", err)
}
log.Printf("[PushService] New subscription created: %s (%s)", subscription.DeviceName, subscription.ID)
return &types.SubscribePushResponse{
Success: true,
Message: "Subscribed successfully",
SubscriptionID: subscription.ID,
DeviceID: subscription.DeviceID,
DeviceName: subscription.DeviceName,
}, nil
}
// Unsubscribe removes a push subscription by endpoint
func (ps *PushService) Unsubscribe(endpoint string) error {
result := ps.db.Where("endpoint = ?", endpoint).Delete(&models.PushSubscription{})
if result.Error != nil {
return fmt.Errorf("failed to unsubscribe: %w", result.Error)
}
if result.RowsAffected == 0 {
return errors.New("subscription not found")
}
log.Printf("[PushService] Subscription unsubscribed: %s", endpoint)
return nil
}
// GetUserDevices returns all active devices for a user
func (ps *PushService) GetUserDevices(userID uuid.UUID) ([]types.DeviceInfo, error) {
var subscriptions []models.PushSubscription
err := ps.db.Where("user_id = ? AND is_active = ?", userID, true).
Order("last_used_at DESC").
Find(&subscriptions).Error
if err != nil {
return nil, err
}
devices := make([]types.DeviceInfo, len(subscriptions))
for i, sub := range subscriptions {
devices[i] = types.DeviceInfo{
ID: sub.ID,
DeviceID: sub.DeviceID,
DeviceName: sub.DeviceName,
Browser: sub.Browser,
OS: sub.OS,
DeviceType: sub.DeviceType,
IsActive: sub.IsActive,
LastUsedAt: sub.LastUsedAt,
CreatedAt: sub.CreatedAt,
}
}
return devices, nil
}
// ==================== SENDING NOTIFICATIONS ====================
// SendToUser sends a notification to all active devices of a user
func (ps *PushService) SendToUser(userID uuid.UUID, payload types.PushPayload) error {
var subscriptions []models.PushSubscription
err := ps.db.Where("user_id = ? AND is_active = ?", userID, true).
Find(&subscriptions).Error
if err != nil {
return fmt.Errorf("failed to fetch subscriptions: %w", err)
}
if len(subscriptions) == 0 {
log.Printf("[PushService] No active subscriptions for user: %s", userID)
return nil // Not an error, just no devices
}
// Send to all devices in parallel using goroutines
for _, subscription := range subscriptions {
go ps.sendToSubscription(subscription, payload)
}
return nil
}
// SendToUserDevice sends a notification to a specific user device
func (ps *PushService) SendToUserDevice(userID uuid.UUID, deviceID string, payload types.PushPayload) error {
var subscription models.PushSubscription
err := ps.db.Where("user_id = ? AND device_id = ? AND is_active = ?", userID, deviceID, true).
First(&subscription).Error
if err != nil {
return fmt.Errorf("device not found: %w", err)
}
return ps.sendToSubscription(subscription, payload)
}
// SendToAdmin sends a notification to all active devices of an admin
func (ps *PushService) SendToAdmin(adminID uuid.UUID, payload types.PushPayload) error {
var subscriptions []models.PushSubscription
err := ps.db.Where("admin_id = ? AND is_active = ?", adminID, true).
Find(&subscriptions).Error
if err != nil {
return fmt.Errorf("failed to fetch subscriptions: %w", err)
}
if len(subscriptions) == 0 {
log.Printf("[PushService] No active subscriptions for admin: %s", adminID)
return nil
}
for _, subscription := range subscriptions {
go ps.sendToSubscription(subscription, payload)
}
return nil
}
// SendBroadcastToAllUsers sends a notification to all active user subscriptions
func (ps *PushService) SendBroadcastToAllUsers(payload types.PushPayload) error {
return ps.sendBroadcast("user_id IS NOT NULL", payload)
}
// SendBroadcastToAllAdmins sends a notification to all active admin subscriptions
func (ps *PushService) SendBroadcastToAllAdmins(payload types.PushPayload) error {
return ps.sendBroadcast("admin_id IS NOT NULL", payload)
}
// ==================== INTERNAL METHODS ====================
// sendToSubscription sends a push notification to a single subscription
func (ps *PushService) sendToSubscription(subscription models.PushSubscription, payload types.PushPayload) error {
// Convert payload to JSON
payloadBytes, err := json.Marshal(payload)
if err != nil {
ps.logFailure(subscription, payload, "Failed to marshal payload", 0)
return fmt.Errorf("failed to marshal payload: %w", err)
}
// Create webpush subscription object
webpushSubscription := &webpush.Subscription{
Endpoint: subscription.Endpoint,
Keys: webpush.Keys{
P256dh: subscription.P256dhKey,
Auth: subscription.AuthKey,
},
}
// Send the notification
resp, err := webpush.SendNotification(payloadBytes, webpushSubscription, &webpush.Options{
Subscriber: ps.vapidSubject,
VAPIDPublicKey: ps.vapidPublicKey,
VAPIDPrivateKey: ps.vapidPrivateKey,
TTL: 2592000, // 30 days in seconds
Urgency: webpush.UrgencyHigh,
})
if err != nil {
ps.handleSendError(subscription, payload, err)
return err
}
defer resp.Body.Close()
// Check response status
if resp.StatusCode == 201 || resp.StatusCode == 200 {
// Success
ps.handleSendSuccess(subscription, payload, resp.StatusCode)
log.Printf("[PushService] ✅ Notification sent successfully to %s (status: %d)",
subscription.DeviceName, resp.StatusCode)
return nil
}
// Handle non-success status codes
if resp.StatusCode == 410 { // Gone - subscription expired
ps.handleExpiredSubscription(subscription, payload, resp.StatusCode)
return errors.New("subscription expired (410 Gone)")
}
// Other error status codes
ps.logFailure(subscription, payload, fmt.Sprintf("HTTP %d", resp.StatusCode), resp.StatusCode)
return fmt.Errorf("push service returned status %d", resp.StatusCode)
}
// handleSendSuccess updates subscription on successful send
func (ps *PushService) handleSendSuccess(subscription models.PushSubscription, payload types.PushPayload, statusCode int) {
now := time.Now()
// Reset failure count and update last used
ps.db.Model(&subscription).Updates(map[string]interface{}{
"failure_count": 0,
"last_used_at": now,
"updated_at": now,
})
// Log success
ps.logSuccess(subscription, payload, statusCode)
}
// handleSendError updates subscription on send error
func (ps *PushService) handleSendError(subscription models.PushSubscription, payload types.PushPayload, err error) {
// Increment failure count
newFailureCount := subscription.FailureCount + 1
updates := map[string]interface{}{
"failure_count": newFailureCount,
"updated_at": time.Now(),
}
// Deactivate after 3 consecutive failures
if newFailureCount >= 3 {
updates["is_active"] = false
log.Printf("[PushService] ⚠️ Subscription deactivated after 3 failures: %s", subscription.DeviceName)
}
ps.db.Model(&subscription).Updates(updates)
// Log failure
ps.logFailure(subscription, payload, err.Error(), 0)
}
// handleExpiredSubscription marks subscription as expired
func (ps *PushService) handleExpiredSubscription(subscription models.PushSubscription, payload types.PushPayload, statusCode int) {
ps.db.Model(&subscription).Updates(map[string]interface{}{
"is_active": false,
"updated_at": time.Now(),
})
ps.logFailure(subscription, payload, "Subscription expired (410 Gone)", statusCode)
log.Printf("[PushService] ⚠️ Subscription expired and deactivated: %s", subscription.DeviceName)
}
// sendBroadcast sends to multiple subscriptions with batching
func (ps *PushService) sendBroadcast(condition string, payload types.PushPayload) error {
// Fetch all matching active subscriptions
var subscriptions []models.PushSubscription
err := ps.db.Where(condition+" AND is_active = ?", true).Find(&subscriptions).Error
if err != nil {
return fmt.Errorf("failed to fetch subscriptions: %w", err)
}
if len(subscriptions) == 0 {
log.Printf("[PushService] No active subscriptions for broadcast")
return nil
}
log.Printf("[PushService] Broadcasting to %d devices", len(subscriptions))
// Send in batches of 100 to avoid overwhelming the system
batchSize := 100
for i := 0; i < len(subscriptions); i += batchSize {
end := i + batchSize
if end > len(subscriptions) {
end = len(subscriptions)
}
batch := subscriptions[i:end]
for _, subscription := range batch {
go ps.sendToSubscription(subscription, payload)
}
// Small delay between batches
if end < len(subscriptions) {
time.Sleep(100 * time.Millisecond)
}
}
return nil
}
// ==================== LOGGING ====================
// logSuccess logs a successful push notification
func (ps *PushService) logSuccess(subscription models.PushSubscription, payload types.PushPayload, statusCode int) {
payloadJSON, _ := json.Marshal(payload)
log := models.PushNotificationLog{
SubscriptionID: &subscription.ID,
UserID: subscription.UserID,
AdminID: subscription.AdminID,
Title: payload.Title,
Body: payload.Body,
Payload: payloadJSON,
Status: "sent",
HTTPStatusCode: statusCode,
}
ps.db.Create(&log)
}
// logFailure logs a failed push notification
func (ps *PushService) logFailure(subscription models.PushSubscription, payload types.PushPayload, errorMessage string, statusCode int) {
payloadJSON, _ := json.Marshal(payload)
status := "failed"
if statusCode == 410 {
status = "expired"
}
log := models.PushNotificationLog{
SubscriptionID: &subscription.ID,
UserID: subscription.UserID,
AdminID: subscription.AdminID,
Title: payload.Title,
Body: payload.Body,
Payload: payloadJSON,
Status: status,
ErrorMessage: errorMessage,
HTTPStatusCode: statusCode,
}
ps.db.Create(&log)
}
HTTP Controllers
controllers/push_controller.go
package controllers
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"your-app/services"
"your-app/types"
"your-app/utils"
)
// PushController handles HTTP requests for push notifications
type PushController struct {
pushService *services.PushService
}
// NewPushController creates a new PushController
func NewPushController(pushService *services.PushService) *PushController {
return &PushController{
pushService: pushService,
}
}
// Subscribe handles POST /api/push/subscribe
func (pc *PushController) Subscribe(c *gin.Context) {
var req types.SubscribePushRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
// Get user ID from JWT context (set by auth middleware)
userID, exists := c.Get("userID")
if !exists {
utils.ErrorResponse(c, http.StatusUnauthorized, "User not authenticated")
return
}
// Check if it's a regular user or admin
isAdmin, _ := c.Get("isAdmin")
var userUUID, adminUUID *uuid.UUID
parsedUUID, err := uuid.Parse(userID.(string))
if err != nil {
utils.ErrorResponse(c, http.StatusBadRequest, "Invalid user ID")
return
}
if isAdmin == true {
adminUUID = &parsedUUID
} else {
userUUID = &parsedUUID
}
// Subscribe
response, err := pc.pushService.Subscribe(userUUID, adminUUID, req)
if err != nil {
utils.ErrorResponse(c, http.StatusBadRequest, err.Error())
return
}
utils.SuccessResponse(c, "Subscription successful", response)
}
// Unsubscribe handles POST /api/push/unsubscribe
func (pc *PushController) Unsubscribe(c *gin.Context) {
var req types.UnsubscribePushRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
if err := pc.pushService.Unsubscribe(req.Endpoint); err != nil {
utils.ErrorResponse(c, http.StatusBadRequest, err.Error())
return
}
utils.SuccessResponse(c, "Unsubscribed successfully", types.UnsubscribePushResponse{
Success: true,
Message: "Unsubscribed successfully",
})
}
// GetDevices handles GET /api/push/devices
func (pc *PushController) GetDevices(c *gin.Context) {
userID, exists := c.Get("userID")
if !exists {
utils.ErrorResponse(c, http.StatusUnauthorized, "User not authenticated")
return
}
parsedUUID, err := uuid.Parse(userID.(string))
if err != nil {
utils.ErrorResponse(c, http.StatusBadRequest, "Invalid user ID")
return
}
devices, err := pc.pushService.GetUserDevices(parsedUUID)
if err != nil {
utils.ErrorResponse(c, http.StatusInternalServerError, "Failed to fetch devices")
return
}
utils.SuccessResponse(c, "Devices fetched successfully", types.GetDevicesResponse{
Success: true,
Count: len(devices),
Devices: devices,
})
}
// SendTestNotification handles POST /api/push/test/:userId (admin only)
func (pc *PushController) SendTestNotification(c *gin.Context) {
userIDStr := c.Param("userId")
userID, err := uuid.Parse(userIDStr)
if err != nil {
utils.ErrorResponse(c, http.StatusBadRequest, "Invalid user ID")
return
}
// Optional: target specific device
deviceID := c.Query("deviceId")
payload := types.PushPayload{
Title: "Test Notification",
Body: "This is a test push notification from your backend",
Tag: "test",
URL: "/",
}
if deviceID != "" {
err = pc.pushService.SendToUserDevice(userID, deviceID, payload)
} else {
err = pc.pushService.SendToUser(userID, payload)
}
if err != nil {
utils.ErrorResponse(c, http.StatusInternalServerError, "Failed to send notification")
return
}
utils.SuccessResponse(c, "Test notification sent successfully", gin.H{
"userId": userID,
"deviceId": deviceID,
})
}
Route Configuration
routes/push_routes.go
package routes
import (
"github.com/gin-gonic/gin"
"your-app/controllers"
"your-app/middleware"
)
// SetupPushRoutes configures push notification routes
func SetupPushRoutes(router *gin.Engine, pushController *controllers.PushController) {
push := router.Group("/api/push")
{
// Public routes (require authentication only)
push.Use(middleware.AuthMiddleware())
{
push.POST("/subscribe", pushController.Subscribe)
push.POST("/unsubscribe", pushController.Unsubscribe)
push.GET("/devices", pushController.GetDevices)
}
// Admin routes (require admin role)
push.Use(middleware.AdminMiddleware())
{
push.POST("/test/:userId", pushController.SendTestNotification)
}
}
}
Middleware
middleware/auth.go
package middleware
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"os"
)
// AuthMiddleware validates JWT tokens
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// Get token from Authorization header or cookie
tokenString := extractToken(c)
if tokenString == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "No token provided"})
c.Abort()
return
}
// Parse and validate token
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
return []byte(os.Getenv("JWT_SECRET")), nil
})
if err != nil || !token.Valid {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
c.Abort()
return
}
// Extract claims
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token claims"})
c.Abort()
return
}
// Set user context
c.Set("userID", claims["sub"].(string))
c.Set("isAdmin", claims["isAdmin"])
c.Next()
}
}
// AdminMiddleware checks if user is an admin
func AdminMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
isAdmin, exists := c.Get("isAdmin")
if !exists || isAdmin != true {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
c.Abort()
return
}
c.Next()
}
}
// extractToken extracts JWT from Authorization header or cookie
func extractToken(c *gin.Context) string {
// Try Authorization header first
authHeader := c.GetHeader("Authorization")
if authHeader != "" {
parts := strings.Split(authHeader, " ")
if len(parts) == 2 && parts[0] == "Bearer" {
return parts[1]
}
}
// Try cookie
token, err := c.Cookie("auth-token")
if err == nil {
return token
}
return ""
}
Utilities
utils/response.go
package utils
import (
"github.com/gin-gonic/gin"
"net/http"
)
// SuccessResponse sends a standardized success response
func SuccessResponse(c *gin.Context, message string, data interface{}) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": message,
"data": data,
})
}
// ErrorResponse sends a standardized error response
func ErrorResponse(c *gin.Context, statusCode int, message string) {
c.JSON(statusCode, gin.H{
"success": false,
"error": message,
})
}
utils/device_detector.go
package utils
import (
"strings"
"your-app/types"
)
// ParseUserAgent parses a user agent string and returns device metadata
func ParseUserAgent(userAgent string) types.DeviceMetadata {
metadata := types.DeviceMetadata{
DeviceName: "Unknown Device",
Browser: "Unknown",
OS: "Unknown",
DeviceType: "desktop", // Default
}
if userAgent == "" {
return metadata
}
ua := strings.ToLower(userAgent)
// Detect Browser
switch {
case strings.Contains(ua, "edg/"):
metadata.Browser = "Edge"
case strings.Contains(ua, "chrome/") || strings.Contains(ua, "crios/"):
metadata.Browser = "Chrome"
case strings.Contains(ua, "firefox/") || strings.Contains(ua, "fxios/"):
metadata.Browser = "Firefox"
case strings.Contains(ua, "safari/") && !strings.Contains(ua, "chrome"):
metadata.Browser = "Safari"
case strings.Contains(ua, "opera/") || strings.Contains(ua, "opr/"):
metadata.Browser = "Opera"
}
// Detect OS
switch {
case strings.Contains(ua, "windows"):
metadata.OS = "Windows"
case strings.Contains(ua, "mac os x") || strings.Contains(ua, "macos"):
metadata.OS = "macOS"
case strings.Contains(ua, "linux"):
metadata.OS = "Linux"
case strings.Contains(ua, "android"):
metadata.OS = "Android"
case strings.Contains(ua, "iphone") || strings.Contains(ua, "ipad"):
metadata.OS = "iOS"
}
// Detect Device Type
switch {
case strings.Contains(ua, "mobile") || strings.Contains(ua, "android") ||
strings.Contains(ua, "iphone"):
metadata.DeviceType = "mobile"
case strings.Contains(ua, "tablet") || strings.Contains(ua, "ipad"):
metadata.DeviceType = "tablet"
default:
metadata.DeviceType = "desktop"
}
// Build device name
metadata.DeviceName = metadata.Browser + " on " + metadata.OS
return metadata
}
Main Application
cmd/server/main.go
package main
import (
"log"
"os"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"github.com/joho/godotenv"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"your-app/controllers"
"your-app/models"
"your-app/routes"
"your-app/services"
)
func main() {
// Load environment variables
if err := godotenv.Load(); err != nil {
log.Println("No .env file found")
}
// Connect to database
db, err := connectDB()
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
}
// Run migrations
if err := runMigrations(db); err != nil {
log.Fatalf("Failed to run migrations: %v", err)
}
// Initialize services
pushService := services.NewPushService(db)
// Initialize controllers
pushController := controllers.NewPushController(pushService)
// Setup Gin router
router := gin.Default()
// CORS middleware
router.Use(cors.New(cors.Config{
AllowOrigins: []string{"http://localhost:3000", "http://localhost:3001"},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization"},
AllowCredentials: true,
}))
// Setup routes
routes.SetupPushRoutes(router, pushController)
// Health check
router.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
})
// Start server
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
log.Printf("Starting server on port %s", port)
if err := router.Run(":" + port); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
}
func connectDB() (*gorm.DB, error) {
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
dsn = "host=localhost user=postgres password=postgres dbname=your_app port=5432 sslmode=disable"
}
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
return nil, err
}
log.Println("Database connected successfully")
return db, nil
}
func runMigrations(db *gorm.DB) error {
return db.AutoMigrate(
&models.PushSubscription{},
&models.PushNotificationLog{},
)
}
Testing
Basic Testing Example
package services_test
import (
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"your-app/models"
"your-app/services"
"your-app/types"
)
func setupTestDB(t *testing.T) *gorm.DB {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("Failed to connect to test database: %v", err)
}
// Run migrations
db.AutoMigrate(&models.PushSubscription{}, &models.PushNotificationLog{})
return db
}
func TestPushService_Subscribe(t *testing.T) {
db := setupTestDB(t)
pushService := services.NewPushService(db)
userID := uuid.New()
req := types.SubscribePushRequest{
Endpoint: "https://fcm.googleapis.com/test-endpoint",
Keys: types.SubscriptionKeys{
P256dh: "test-p256dh-key",
Auth: "test-auth-key",
},
UserAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/120.0.0.0",
}
// Test subscription
response, err := pushService.Subscribe(&userID, nil, req)
assert.NoError(t, err)
assert.True(t, response.Success)
assert.NotEqual(t, uuid.Nil, response.SubscriptionID)
assert.Contains(t, response.DeviceName, "Chrome")
// Verify database
var subscription models.PushSubscription
err = db.Where("endpoint = ?", req.Endpoint).First(&subscription).Error
assert.NoError(t, err)
assert.Equal(t, userID, *subscription.UserID)
assert.True(t, subscription.IsActive)
}
func TestPushService_DeviceLimit(t *testing.T) {
db := setupTestDB(t)
pushService := services.NewPushService(db)
userID := uuid.New()
// Create 5 subscriptions (limit)
for i := 0; i < 5; i++ {
req := types.SubscribePushRequest{
Endpoint: fmt.Sprintf("https://fcm.googleapis.com/endpoint-%d", i),
Keys: types.SubscriptionKeys{
P256dh: "test-key",
Auth: "test-auth",
},
}
_, err := pushService.Subscribe(&userID, nil, req)
assert.NoError(t, err)
}
// Try to create 6th subscription (should fail)
req := types.SubscribePushRequest{
Endpoint: "https://fcm.googleapis.com/endpoint-6",
Keys: types.SubscriptionKeys{
P256dh: "test-key",
Auth: "test-auth",
},
}
_, err := pushService.Subscribe(&userID, nil, req)
assert.Error(t, err)
assert.Contains(t, err.Error(), "device limit reached")
}
Next Steps
✅ Backend implementation complete!
Now proceed to:
➡️ Part 3: Frontend Implementation (Next.js)
Part 3 will cover:
- Next.js API routes (SSR forwarding)
- PushNotificationManager component
- usePushNotifications hook
- TypeScript type definitions
- Complete frontend integration
Summary
You now have a complete, production-ready backend for push notifications with:
✅ GORM models with health tracking
✅ Non-blocking goroutine-based sending
✅ Multi-device support (up to 5 devices)
✅ Automatic failure handling and deactivation
✅ Complete audit logging
✅ Advanced targeting (user, admin, broadcast)
✅ RESTful API with JWT authentication
✅ Device metadata detection
✅ Comprehensive error handling
The backend can handle thousands of simultaneous push notifications efficiently using goroutines.
Top comments (0)