Skip to content

Commit 59cb3a1

Browse files
committed
feat: Refactor push notification service and add notification payload models
1 parent 921661f commit 59cb3a1

File tree

8 files changed

+443
-21
lines changed

8 files changed

+443
-21
lines changed

go.mod

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ module trakrlog
33
go 1.25.4
44

55
require (
6+
github.com/SherClockHolmes/webpush-go v1.4.0
67
github.com/gin-contrib/cors v1.7.6
78
github.com/gin-gonic/gin v1.11.0
89
github.com/gorilla/sessions v1.1.1
@@ -14,12 +15,12 @@ require (
1415

1516
require (
1617
cloud.google.com/go/compute/metadata v0.7.0 // indirect
17-
github.com/SherClockHolmes/webpush-go v1.4.0 // indirect
1818
github.com/go-chi/chi/v5 v5.2.2 // indirect
1919
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
2020
github.com/gorilla/context v1.1.1 // indirect
2121
github.com/gorilla/mux v1.6.2 // indirect
2222
github.com/gorilla/securecookie v1.1.1 // indirect
23+
github.com/stretchr/objx v0.5.2 // indirect
2324
golang.org/x/oauth2 v0.33.0 // indirect
2425
)
2526

internal/model/push.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package model
2+
3+
// NotificationPayload represents the data sent in a push notification
4+
type NotificationPayload struct {
5+
Title string `json:"title"`
6+
Body string `json:"body"`
7+
Icon string `json:"icon,omitempty"`
8+
Badge string `json:"badge,omitempty"`
9+
Tag string `json:"tag,omitempty"`
10+
Data map[string]interface{} `json:"data,omitempty"`
11+
Actions []NotificationAction `json:"actions,omitempty"`
12+
}
13+
14+
// NotificationAction represents an action button in the notification
15+
type NotificationAction struct {
16+
Action string `json:"action"`
17+
Title string `json:"title"`
18+
Icon string `json:"icon,omitempty"`
19+
}

internal/service/mocks/channel.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package mocks
2+
3+
import (
4+
"context"
5+
6+
"github.com/stretchr/testify/mock"
7+
"trakrlog/internal/model"
8+
)
9+
10+
// MockChannelService is a mock implementation of ChannelServiceInterface
11+
type MockChannelService struct {
12+
mock.Mock
13+
}
14+
15+
func (m *MockChannelService) GetChannelByID(ctx context.Context, id string) (*model.Channel, error) {
16+
args := m.Called(ctx, id)
17+
if args.Get(0) == nil {
18+
return nil, args.Error(1)
19+
}
20+
return args.Get(0).(*model.Channel), args.Error(1)
21+
}

internal/service/mocks/project.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package mocks
2+
3+
import (
4+
"context"
5+
6+
"github.com/stretchr/testify/mock"
7+
"trakrlog/internal/model"
8+
)
9+
10+
// MockProjectService is a mock implementation of ProjectServiceInterface
11+
type MockProjectService struct {
12+
mock.Mock
13+
}
14+
15+
func (m *MockProjectService) GetProjectByID(ctx context.Context, id string) (*model.Project, error) {
16+
args := m.Called(ctx, id)
17+
if args.Get(0) == nil {
18+
return nil, args.Error(1)
19+
}
20+
return args.Get(0).(*model.Project), args.Error(1)
21+
}

internal/service/mocks/push.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package mocks
2+
3+
import (
4+
"context"
5+
6+
"github.com/stretchr/testify/mock"
7+
"trakrlog/internal/model"
8+
)
9+
10+
// MockPushService is a mock implementation of PushServiceInterface
11+
type MockPushService struct {
12+
mock.Mock
13+
}
14+
15+
func (m *MockPushService) SendToUser(ctx context.Context, userID string, payload *model.NotificationPayload, eventID string) error {
16+
args := m.Called(ctx, userID, payload, eventID)
17+
return args.Error(0)
18+
}

internal/service/notification.go

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
package service
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"log"
7+
8+
"trakrlog/internal/model"
9+
)
10+
11+
// PushServiceInterface defines the interface for push notification operations
12+
type PushServiceInterface interface {
13+
SendToUser(ctx context.Context, userID string, payload *model.NotificationPayload, eventID string) error
14+
}
15+
16+
// ProjectServiceInterface defines the interface for project operations
17+
type ProjectServiceInterface interface {
18+
GetProjectByID(ctx context.Context, id string) (*model.Project, error)
19+
}
20+
21+
// ChannelServiceInterface defines the interface for channel operations
22+
type ChannelServiceInterface interface {
23+
GetChannelByID(ctx context.Context, id string) (*model.Channel, error)
24+
}
25+
26+
// NotificationService handles the business logic for sending notifications based on events
27+
type NotificationService struct {
28+
pushService PushServiceInterface
29+
projectService ProjectServiceInterface
30+
channelService ChannelServiceInterface
31+
}
32+
33+
// NewNotificationService creates a new notification service
34+
func NewNotificationService(
35+
pushService PushServiceInterface,
36+
projectService ProjectServiceInterface,
37+
channelService ChannelServiceInterface,
38+
) *NotificationService {
39+
return &NotificationService{
40+
pushService: pushService,
41+
projectService: projectService,
42+
channelService: channelService,
43+
}
44+
}
45+
46+
// ProcessEventNotification processes an event and sends notifications to subscribed users
47+
// In the MVP, all events trigger notifications for users with active subscriptions
48+
func (s *NotificationService) ProcessEventNotification(ctx context.Context, event *model.Event) error {
49+
// Get project details to build notification context
50+
project, err := s.projectService.GetProjectByID(ctx, event.ProjectID.Hex())
51+
if err != nil {
52+
return fmt.Errorf("failed to get project: %w", err)
53+
}
54+
55+
// Get channel details
56+
channel, err := s.channelService.GetChannelByID(ctx, event.ChannelID.Hex())
57+
if err != nil {
58+
return fmt.Errorf("failed to get channel: %w", err)
59+
}
60+
61+
// Build notification payload
62+
payload := s.buildNotificationPayload(event, project, channel)
63+
64+
// Send notification to the project owner
65+
// Note: In MVP, we send to project owner. Future enhancement: support channel subscribers
66+
userID := project.UserID.Hex()
67+
68+
log.Printf("Processing notification for event %s to user %s", event.ID.Hex(), userID)
69+
70+
if err := s.pushService.SendToUser(ctx, userID, payload, event.ID.Hex()); err != nil {
71+
// Log the error but don't fail event creation
72+
log.Printf("Failed to send notification for event %s: %v", event.ID.Hex(), err)
73+
return fmt.Errorf("failed to send notification: %w", err)
74+
}
75+
76+
return nil
77+
}
78+
79+
// buildNotificationPayload creates the notification payload from event data
80+
func (s *NotificationService) buildNotificationPayload(
81+
event *model.Event,
82+
project *model.Project,
83+
channel *model.Channel,
84+
) *model.NotificationPayload {
85+
// Build notification title
86+
title := fmt.Sprintf("[%s] %s", project.Name, event.Title)
87+
if len(title) > 80 {
88+
title = title[:77] + "..."
89+
}
90+
91+
// Build notification body
92+
body := event.Description
93+
if body == "" {
94+
body = fmt.Sprintf("New event in %s", channel.Name)
95+
}
96+
if len(body) > 120 {
97+
body = body[:117] + "..."
98+
}
99+
100+
// Use event icon if available, otherwise use project logo or default
101+
icon := event.Icon
102+
if icon == "" && project.LogoBase64 != "" {
103+
icon = project.LogoBase64
104+
}
105+
if icon == "" {
106+
icon = "/icon.png" // Default app icon
107+
}
108+
109+
// Build the notification data with event metadata
110+
data := map[string]interface{}{
111+
"eventId": event.ID.Hex(),
112+
"projectId": project.ID.Hex(),
113+
"channelId": channel.ID.Hex(),
114+
"url": fmt.Sprintf("/projects/%s/channels/%s/events/%s", project.ID.Hex(), channel.ID.Hex(), event.ID.Hex()),
115+
"timestamp": event.CreatedAt.Unix(),
116+
}
117+
118+
// Add tags if present
119+
if len(event.Tags) > 0 {
120+
data["tags"] = event.Tags
121+
}
122+
123+
return &model.NotificationPayload{
124+
Title: title,
125+
Body: body,
126+
Icon: icon,
127+
Badge: "/badge.png",
128+
Tag: fmt.Sprintf("event-%s", event.ID.Hex()), // Group notifications by event
129+
Data: data,
130+
Actions: []model.NotificationAction{
131+
{
132+
Action: "view",
133+
Title: "View Event",
134+
},
135+
{
136+
Action: "close",
137+
Title: "Dismiss",
138+
},
139+
},
140+
}
141+
}
142+
143+
// SendTestNotification sends a test notification to verify the setup
144+
func (s *NotificationService) SendTestNotification(ctx context.Context, userID string) error {
145+
payload := &model.NotificationPayload{
146+
Title: "🔔 Test Notification",
147+
Body: "Your push notifications are working correctly!",
148+
Icon: "/icon.png",
149+
Badge: "/badge.png",
150+
Tag: "test-notification",
151+
Data: map[string]interface{}{
152+
"type": "test",
153+
"url": "/settings",
154+
},
155+
Actions: []model.NotificationAction{
156+
{
157+
Action: "view",
158+
Title: "Open Settings",
159+
},
160+
},
161+
}
162+
163+
if err := s.pushService.SendToUser(ctx, userID, payload, ""); err != nil {
164+
return fmt.Errorf("failed to send test notification: %w", err)
165+
}
166+
167+
log.Printf("Test notification sent successfully to user %s", userID)
168+
return nil
169+
}

0 commit comments

Comments
 (0)