DEV Community

Bipin C
Bipin C

Posted on

# Self-Hosted Push Notifications Part-2

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

  1. Dependencies
  2. GORM Models
  3. Type Definitions
  4. Push Service Implementation
  5. HTTP Controllers
  6. Route Configuration
  7. Middleware
  8. Utilities
  9. Main Application
  10. 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
Enter fullscreen mode Exit fullscreen mode

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
)
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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,
    })
}
Enter fullscreen mode Exit fullscreen mode

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)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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 ""
}
Enter fullscreen mode Exit fullscreen mode

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,
    })
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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{},
    )
}
Enter fullscreen mode Exit fullscreen mode

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")
}
Enter fullscreen mode Exit fullscreen mode

Next Steps

Backend implementation complete!

Now proceed to:

➡️ Part 3: Frontend Implementation (Next.js)

Part 3 will cover:

  1. Next.js API routes (SSR forwarding)
  2. PushNotificationManager component
  3. usePushNotifications hook
  4. TypeScript type definitions
  5. 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)