Skip to content

Commit 921661f

Browse files
committed
feat: Add PushService for sending Web Push notifications and update dependencies
1 parent d475962 commit 921661f

4 files changed

Lines changed: 239 additions & 3 deletions

File tree

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ require (
1414

1515
require (
1616
cloud.google.com/go/compute/metadata v0.7.0 // indirect
17+
github.com/SherClockHolmes/webpush-go v1.4.0 // indirect
1718
github.com/go-chi/chi/v5 v5.2.2 // indirect
19+
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
1820
github.com/gorilla/context v1.1.1 // indirect
1921
github.com/gorilla/mux v1.6.2 // indirect
2022
github.com/gorilla/securecookie v1.1.1 // indirect

go.sum

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOEl
88
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
99
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
1010
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
11+
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
12+
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
1113
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
1214
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
1315
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
@@ -76,9 +78,13 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
7678
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
7779
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
7880
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
81+
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
82+
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
83+
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
7984
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
8085
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
8186
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
87+
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
8288
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
8389
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
8490
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -227,18 +233,36 @@ golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
227233
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
228234
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
229235
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
236+
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
237+
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
238+
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
239+
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
230240
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
231241
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
232242
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
243+
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
244+
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
245+
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
246+
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
233247
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
234248
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
235249
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
250+
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
251+
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
252+
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
253+
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
254+
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
236255
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
237256
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
238257
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
239258
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
240259
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
241260
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
261+
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
262+
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
263+
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
264+
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
265+
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
242266
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
243267
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
244268
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -250,26 +274,47 @@ golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBc
250274
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
251275
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
252276
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
277+
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
253278
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
254279
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
255280
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
281+
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
282+
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
283+
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
284+
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
256285
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
257286
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
287+
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
258288
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
259289
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
290+
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
291+
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
292+
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
293+
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
294+
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
295+
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
260296
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
261297
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
262298
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
263299
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
264300
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
265301
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
302+
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
303+
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
304+
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
305+
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
306+
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
307+
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
266308
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
267309
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
268310
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
269311
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
270312
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
271313
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
272314
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
315+
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
316+
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
317+
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
273318
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
274319
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
275320
google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4 h1:8XJ4pajGwOlasW+L13MnEGA8W4115jJySQtVfS2/IBU=

internal/auth/vapid.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,14 @@ func GenerateVAPIDKeys() (*VapidKeys, error) {
3838
// First byte is 0x04 (indicating uncompressed point), followed by X and Y coordinates
3939
publicKeyBytes := make([]byte, 65)
4040
publicKeyBytes[0] = 0x04
41-
41+
4242
xBytes := privateKey.PublicKey.X.Bytes()
4343
yBytes := privateKey.PublicKey.Y.Bytes()
44-
44+
4545
// Ensure X and Y are 32 bytes each (pad with zeros if needed)
4646
copy(publicKeyBytes[1+32-len(xBytes):33], xBytes)
4747
copy(publicKeyBytes[33+32-len(yBytes):65], yBytes)
48-
48+
4949
publicKeyEncoded := base64.RawURLEncoding.EncodeToString(publicKeyBytes)
5050

5151
return &VapidKeys{

internal/service/push.go

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
package service
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"log"
8+
"net/http"
9+
"time"
10+
11+
"trakrlog/internal/model"
12+
"trakrlog/internal/repository"
13+
14+
webpush "github.com/SherClockHolmes/webpush-go"
15+
"go.mongodb.org/mongo-driver/bson/primitive"
16+
)
17+
18+
// PushService handles sending Web Push notifications
19+
type PushService struct {
20+
subscriptionRepo repository.NotificationSubscriptionRepository
21+
logRepo repository.NotificationLogRepository
22+
vapidPublicKey string
23+
vapidPrivateKey string
24+
vapidSubject string
25+
}
26+
27+
// NewPushService creates a new push notification service
28+
func NewPushService(
29+
subscriptionRepo repository.NotificationSubscriptionRepository,
30+
logRepo repository.NotificationLogRepository,
31+
vapidPublicKey string,
32+
vapidPrivateKey string,
33+
vapidSubject string,
34+
) *PushService {
35+
return &PushService{
36+
subscriptionRepo: subscriptionRepo,
37+
logRepo: logRepo,
38+
vapidPublicKey: vapidPublicKey,
39+
vapidPrivateKey: vapidPrivateKey,
40+
vapidSubject: vapidSubject,
41+
}
42+
}
43+
44+
// NotificationPayload represents the data sent in a push notification
45+
type NotificationPayload struct {
46+
Title string `json:"title"`
47+
Body string `json:"body"`
48+
Icon string `json:"icon,omitempty"`
49+
Badge string `json:"badge,omitempty"`
50+
Tag string `json:"tag,omitempty"`
51+
Data map[string]interface{} `json:"data,omitempty"`
52+
Actions []NotificationAction `json:"actions,omitempty"`
53+
}
54+
55+
// NotificationAction represents an action button in the notification
56+
type NotificationAction struct {
57+
Action string `json:"action"`
58+
Title string `json:"title"`
59+
Icon string `json:"icon,omitempty"`
60+
}
61+
62+
// SendNotification sends a push notification to a specific subscription
63+
func (s *PushService) SendNotification(ctx context.Context, subscription *model.NotificationSubscription, payload *NotificationPayload) error {
64+
// Marshal payload to JSON
65+
payloadBytes, err := json.Marshal(payload)
66+
if err != nil {
67+
return fmt.Errorf("failed to marshal payload: %w", err)
68+
}
69+
70+
// Create the Web Push subscription
71+
pushSubscription := &webpush.Subscription{
72+
Endpoint: subscription.Endpoint,
73+
Keys: webpush.Keys{
74+
P256dh: subscription.P256dh,
75+
Auth: subscription.Auth,
76+
},
77+
}
78+
79+
// Send the notification with retry logic
80+
var lastErr error
81+
maxRetries := 3
82+
for attempt := 0; attempt < maxRetries; attempt++ {
83+
if attempt > 0 {
84+
// Exponential backoff: 1s, 2s, 4s
85+
backoff := time.Duration(1<<uint(attempt-1)) * time.Second
86+
time.Sleep(backoff)
87+
log.Printf("Retrying push notification (attempt %d/%d) for subscription %s", attempt+1, maxRetries, subscription.ID.Hex())
88+
}
89+
90+
// Send the push notification
91+
resp, err := webpush.SendNotification(payloadBytes, pushSubscription, &webpush.Options{
92+
Subscriber: s.vapidSubject,
93+
VAPIDPublicKey: s.vapidPublicKey,
94+
VAPIDPrivateKey: s.vapidPrivateKey,
95+
TTL: 3600, // Time to live in seconds (1 hour)
96+
})
97+
98+
if err != nil {
99+
lastErr = err
100+
// Don't retry on certain errors
101+
if resp != nil && (resp.StatusCode == http.StatusGone || resp.StatusCode == http.StatusNotFound) {
102+
break
103+
}
104+
continue
105+
}
106+
107+
// Check response status
108+
if resp.StatusCode == http.StatusGone || resp.StatusCode == http.StatusNotFound {
109+
// Subscription is no longer valid, disable it
110+
log.Printf("Subscription %s is no longer valid (status %d), disabling", subscription.ID.Hex(), resp.StatusCode)
111+
subscription.Enabled = false
112+
if err := s.subscriptionRepo.Update(ctx, subscription); err != nil {
113+
log.Printf("Failed to disable invalid subscription %s: %v", subscription.ID.Hex(), err)
114+
}
115+
return fmt.Errorf("subscription no longer valid: status %d", resp.StatusCode)
116+
}
117+
118+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
119+
lastErr = fmt.Errorf("push service returned status %d", resp.StatusCode)
120+
continue
121+
}
122+
123+
// Success
124+
resp.Body.Close()
125+
return nil
126+
}
127+
128+
return fmt.Errorf("failed to send notification after %d attempts: %w", maxRetries, lastErr)
129+
}
130+
131+
// SendToUser sends a notification to all active subscriptions for a user
132+
// A user may have multiple subscriptions (one per device/browser they use)
133+
// For example: desktop Chrome, mobile Firefox, laptop Edge, etc.
134+
func (s *PushService) SendToUser(ctx context.Context, userID string, payload *NotificationPayload, eventID string) error {
135+
// Get all active subscriptions for the user
136+
subscriptions, err := s.subscriptionRepo.FindActiveByUserID(ctx, userID)
137+
if err != nil {
138+
return fmt.Errorf("failed to get user subscriptions: %w", err)
139+
}
140+
141+
if len(subscriptions) == 0 {
142+
log.Printf("No active subscriptions found for user %s", userID)
143+
return nil
144+
}
145+
146+
// Send to all subscriptions
147+
successCount := 0
148+
for _, subscription := range subscriptions {
149+
err := s.SendNotification(ctx, subscription, payload)
150+
151+
// Log the notification attempt
152+
status := "sent"
153+
errorMsg := ""
154+
if err != nil {
155+
status = "failed"
156+
errorMsg = err.Error()
157+
log.Printf("Failed to send notification to subscription %s: %v", subscription.ID.Hex(), err)
158+
} else {
159+
successCount++
160+
}
161+
162+
// Create notification log
163+
notificationLog := &model.NotificationLog{
164+
UserID: subscription.UserID,
165+
SubscriptionID: subscription.ID,
166+
Status: status,
167+
Error: errorMsg,
168+
}
169+
170+
// Add event ID if provided
171+
if eventID != "" {
172+
eventObjID, parseErr := primitive.ObjectIDFromHex(eventID)
173+
if parseErr == nil {
174+
notificationLog.EventID = eventObjID
175+
}
176+
}
177+
178+
if logErr := s.logRepo.Create(ctx, notificationLog); logErr != nil {
179+
log.Printf("Failed to create notification log: %v", logErr)
180+
}
181+
}
182+
183+
if successCount == 0 {
184+
return fmt.Errorf("failed to send notification to any of %d subscriptions", len(subscriptions))
185+
}
186+
187+
log.Printf("Successfully sent notification to %d/%d subscriptions for user %s", successCount, len(subscriptions), userID)
188+
return nil
189+
}

0 commit comments

Comments
 (0)