feat(notifications): N1.1-N1.3 Web Push subscription, send on events, preferences
- N1.1: POST /notifications/push/subscribe, PushService, migration 090 - N1.2: Send Web Push on follow/like/comment/message via CreateNotification - N1.3: GET/PUT /notifications/preferences, migration 093 - Shared NotificationService with PushService for profile, track, comment handlers - Fix MockSocialService GetGlobalFeed, GetTrendingHashtags for tests
This commit is contained in:
parent
d2a55b405e
commit
49e3122e78
12 changed files with 386 additions and 23 deletions
|
|
@ -51,6 +51,7 @@ require (
|
|||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/PuerkitoBio/purell v1.1.1 // indirect
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tN
|
|||
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
|
||||
|
|
@ -149,6 +151,7 @@ github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
|||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
|
|
@ -348,6 +351,10 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
|
|||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
|
|
@ -356,6 +363,10 @@ golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4
|
|||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
|
|
@ -365,7 +376,12 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
|
|||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
|
|
@ -374,6 +390,11 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ
|
|||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
|
|
@ -392,12 +413,22 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
||||
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
|
|
@ -405,6 +436,11 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
|||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
|
|
@ -414,6 +450,9 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn
|
|||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
|
|
|||
|
|
@ -26,13 +26,15 @@ import (
|
|||
|
||||
// APIRouter gère la configuration des routes de l'API
|
||||
type APIRouter struct {
|
||||
db *database.Database
|
||||
config *config.Config
|
||||
engine *gin.Engine
|
||||
logger *zap.Logger
|
||||
versionManager *VersionManager // BE-SVC-019: API versioning manager
|
||||
monitoringService *services.MonitoringAlertingService // INT-021: API monitoring and alerting
|
||||
authService *authcore.AuthService // Set by setupAuthRoutes for admin unlock
|
||||
db *database.Database
|
||||
config *config.Config
|
||||
engine *gin.Engine
|
||||
logger *zap.Logger
|
||||
versionManager *VersionManager // BE-SVC-019: API versioning manager
|
||||
monitoringService *services.MonitoringAlertingService // INT-021: API monitoring and alerting
|
||||
authService *authcore.AuthService // Set by setupAuthRoutes for admin unlock
|
||||
notificationService *services.NotificationService // Shared for N1.2 Web Push
|
||||
pushService *services.PushService // N1 Web Push
|
||||
}
|
||||
|
||||
// NewAPIRouter crée une nouvelle instance de APIRouter
|
||||
|
|
|
|||
|
|
@ -345,12 +345,19 @@ func (r *APIRouter) setupCoreProtectedRoutes(v1 *gin.RouterGroup) {
|
|||
conversations.GET("/:id/history", roomHandler.GetRoomHistory)
|
||||
}
|
||||
|
||||
notificationService := services.NewNotificationService(r.db, r.logger)
|
||||
handlers.NewNotificationHandlers(notificationService)
|
||||
r.notificationService = services.NewNotificationService(r.db, r.logger)
|
||||
vapidPublic := os.Getenv("VAPID_PUBLIC_KEY")
|
||||
vapidPrivate := os.Getenv("VAPID_PRIVATE_KEY")
|
||||
r.pushService = services.NewPushService(r.db.GormDB, r.logger, vapidPublic, vapidPrivate)
|
||||
r.notificationService.SetPushService(r.pushService)
|
||||
handlers.NewNotificationHandlers(r.notificationService, r.pushService)
|
||||
notifications := protected.Group("/notifications")
|
||||
{
|
||||
notifications.GET("", handlers.NotificationHandlersInstance.GetNotifications)
|
||||
notifications.GET("/unread-count", handlers.NotificationHandlersInstance.GetUnreadCount)
|
||||
notifications.GET("/preferences", handlers.NotificationHandlersInstance.GetPreferences)
|
||||
notifications.PUT("/preferences", handlers.NotificationHandlersInstance.UpdatePreferences)
|
||||
notifications.POST("/push/subscribe", handlers.NotificationHandlersInstance.SubscribePush)
|
||||
notifications.POST("/:id/read", handlers.NotificationHandlersInstance.MarkAsRead)
|
||||
notifications.POST("/read-all", handlers.NotificationHandlersInstance.MarkAllAsRead)
|
||||
notifications.DELETE("/:id", handlers.NotificationHandlersInstance.DeleteNotification)
|
||||
|
|
|
|||
|
|
@ -71,8 +71,13 @@ func (r *APIRouter) setupTrackRoutes(router *gin.RouterGroup) {
|
|||
licenseChecker := services.NewDBTrackDownloadLicenseChecker(r.db.GormDB, r.logger)
|
||||
trackHandler.SetLicenseChecker(licenseChecker)
|
||||
|
||||
notificationService := services.NewNotificationService(r.db, r.logger)
|
||||
trackHandler.SetNotificationService(notificationService)
|
||||
if r.notificationService == nil {
|
||||
r.notificationService = services.NewNotificationService(r.db, r.logger)
|
||||
if r.pushService != nil {
|
||||
r.notificationService.SetPushService(r.pushService)
|
||||
}
|
||||
}
|
||||
trackHandler.SetNotificationService(r.notificationService)
|
||||
|
||||
trackRecommendationService := services.NewTrackRecommendationService(r.db.GormDB, r.logger)
|
||||
trackHandler.SetTrackRecommendationService(trackRecommendationService)
|
||||
|
|
@ -150,7 +155,7 @@ func (r *APIRouter) setupTrackRoutes(router *gin.RouterGroup) {
|
|||
|
||||
commentService := services.NewCommentService(r.db.GormDB, r.logger)
|
||||
commentHandler := handlers.NewCommentHandler(commentService, r.logger)
|
||||
commentHandler.SetNotificationService(notificationService)
|
||||
commentHandler.SetNotificationService(r.notificationService)
|
||||
|
||||
comments := router.Group("/tracks")
|
||||
{
|
||||
|
|
|
|||
|
|
@ -26,8 +26,13 @@ func (r *APIRouter) setupUserRoutes(router *gin.RouterGroup) {
|
|||
}
|
||||
socialService := services.NewSocialService(r.db, r.logger)
|
||||
profileHandler.SetSocialService(socialService)
|
||||
notificationService := services.NewNotificationService(r.db, r.logger)
|
||||
profileHandler.SetNotificationService(notificationService)
|
||||
if r.notificationService == nil {
|
||||
r.notificationService = services.NewNotificationService(r.db, r.logger)
|
||||
if r.pushService != nil {
|
||||
r.notificationService.SetPushService(r.pushService)
|
||||
}
|
||||
}
|
||||
profileHandler.SetNotificationService(r.notificationService)
|
||||
|
||||
users := router.Group("/users")
|
||||
{
|
||||
|
|
|
|||
|
|
@ -21,15 +21,19 @@ type NotificationServiceInterface interface {
|
|||
GetUnreadCount(userID uuid.UUID) (int, error)
|
||||
DeleteNotification(userID uuid.UUID, notificationID uuid.UUID) error
|
||||
DeleteAllNotifications(userID uuid.UUID) error
|
||||
GetPreferences(userID uuid.UUID) (*services.NotificationPrefs, error)
|
||||
UpdatePreferences(userID uuid.UUID, pushFollow, pushLike, pushComment, pushMessage, pushMention *bool) error
|
||||
}
|
||||
|
||||
type NotificationHandlers struct {
|
||||
notificationService NotificationServiceInterface
|
||||
pushService *services.PushService
|
||||
}
|
||||
|
||||
func NewNotificationHandlers(notificationService *services.NotificationService) {
|
||||
func NewNotificationHandlers(notificationService *services.NotificationService, pushService *services.PushService) {
|
||||
NotificationHandlersInstance = &NotificationHandlers{
|
||||
notificationService: notificationService,
|
||||
pushService: pushService,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -157,3 +161,90 @@ func (nh *NotificationHandlers) DeleteAllNotifications(c *gin.Context) {
|
|||
|
||||
RespondSuccess(c, http.StatusOK, gin.H{"message": "All notifications deleted"})
|
||||
}
|
||||
|
||||
// SubscribePushRequest is the DTO for Web Push subscription (N1.1)
|
||||
type SubscribePushRequest struct {
|
||||
Endpoint string `json:"endpoint" binding:"required"`
|
||||
Keys struct {
|
||||
P256dh string `json:"p256dh" binding:"required"`
|
||||
Auth string `json:"auth" binding:"required"`
|
||||
} `json:"keys" binding:"required"`
|
||||
}
|
||||
|
||||
// SubscribePush stores a Web Push subscription (N1.1)
|
||||
func (nh *NotificationHandlers) SubscribePush(c *gin.Context) {
|
||||
userID, ok := GetUserIDUUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req SubscribePushRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
RespondWithAppError(c, apperrors.NewValidationError("invalid request body"))
|
||||
return
|
||||
}
|
||||
|
||||
if nh.pushService == nil {
|
||||
RespondSuccess(c, http.StatusOK, gin.H{"message": "Push not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := nh.pushService.SubscribePush(c.Request.Context(), userID, req.Endpoint, req.Keys.P256dh, req.Keys.Auth); err != nil {
|
||||
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to subscribe", err))
|
||||
return
|
||||
}
|
||||
|
||||
RespondSuccess(c, http.StatusCreated, gin.H{"message": "Subscribed"})
|
||||
}
|
||||
|
||||
// GetPreferences returns notification preferences (N1.3)
|
||||
func (nh *NotificationHandlers) GetPreferences(c *gin.Context) {
|
||||
userID, ok := GetUserIDUUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
prefs, err := nh.notificationService.GetPreferences(userID)
|
||||
if err != nil {
|
||||
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to get preferences", err))
|
||||
return
|
||||
}
|
||||
|
||||
RespondSuccess(c, http.StatusOK, gin.H{
|
||||
"push_follow": prefs.PushFollow,
|
||||
"push_like": prefs.PushLike,
|
||||
"push_comment": prefs.PushComment,
|
||||
"push_message": prefs.PushMessage,
|
||||
"push_mention": prefs.PushMention,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdatePreferencesRequest is the DTO for updating preferences
|
||||
type UpdatePreferencesRequest struct {
|
||||
PushFollow *bool `json:"push_follow"`
|
||||
PushLike *bool `json:"push_like"`
|
||||
PushComment *bool `json:"push_comment"`
|
||||
PushMessage *bool `json:"push_message"`
|
||||
PushMention *bool `json:"push_mention"`
|
||||
}
|
||||
|
||||
// UpdatePreferences updates notification preferences (N1.3)
|
||||
func (nh *NotificationHandlers) UpdatePreferences(c *gin.Context) {
|
||||
userID, ok := GetUserIDUUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdatePreferencesRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
RespondWithAppError(c, apperrors.NewValidationError("invalid request body"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := nh.notificationService.UpdatePreferences(userID, req.PushFollow, req.PushLike, req.PushComment, req.PushMessage, req.PushMention); err != nil {
|
||||
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to update preferences", err))
|
||||
return
|
||||
}
|
||||
|
||||
RespondSuccess(c, http.StatusOK, gin.H{"message": "Preferences updated"})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,19 @@ func (m *MockNotificationService) DeleteNotification(userID uuid.UUID, notificat
|
|||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockNotificationService) GetPreferences(userID uuid.UUID) (*services.NotificationPrefs, error) {
|
||||
args := m.Called(userID)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*services.NotificationPrefs), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockNotificationService) UpdatePreferences(userID uuid.UUID, pushFollow, pushLike, pushComment, pushMessage, pushMention *bool) error {
|
||||
args := m.Called(userID, pushFollow, pushLike, pushComment, pushMessage, pushMention)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func setupTestNotificationRouter(mockService *MockNotificationService) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
|
|
@ -375,7 +388,7 @@ func TestNewNotificationHandlers(t *testing.T) {
|
|||
mockService := &services.NotificationService{}
|
||||
|
||||
// Execute
|
||||
NewNotificationHandlers(mockService)
|
||||
NewNotificationHandlers(mockService, nil)
|
||||
|
||||
// Assert
|
||||
assert.NotNil(t, NotificationHandlersInstance)
|
||||
|
|
|
|||
|
|
@ -40,8 +40,8 @@ func (m *MockSocialService) GetPostsByUser(ctx context.Context, userID uuid.UUID
|
|||
return args.Get(0).([]social.Post), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockSocialService) GetGlobalFeed(ctx context.Context, limit, offset int) ([]social.FeedItem, error) {
|
||||
args := m.Called(ctx, limit, offset)
|
||||
func (m *MockSocialService) GetGlobalFeed(ctx context.Context, limit, offset int, feedType string, userID *uuid.UUID) ([]social.FeedItem, error) {
|
||||
args := m.Called(ctx, limit, offset, feedType, userID)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
|
@ -74,6 +74,14 @@ func (m *MockSocialService) CreateActivityPost(ctx context.Context, userID uuid.
|
|||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockSocialService) GetTrendingHashtags(ctx context.Context, limit int) ([]social.TrendingTag, error) {
|
||||
args := m.Called(ctx, limit)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).([]social.TrendingTag), args.Error(1)
|
||||
}
|
||||
|
||||
func setupTestSocialRouter(mockService *MockSocialService) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
|
|
@ -553,7 +561,7 @@ func TestSocialHandler_GetFeed_Success(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
mockService.On("GetGlobalFeed", mock.Anything, 20, 0).Return(expectedFeed, nil)
|
||||
mockService.On("GetGlobalFeed", mock.Anything, 20, 0, "all", (*uuid.UUID)(nil)).Return(expectedFeed, nil)
|
||||
|
||||
// Execute
|
||||
req, _ := http.NewRequest("GET", "/api/v1/feed", nil)
|
||||
|
|
@ -570,7 +578,7 @@ func TestSocialHandler_GetFeed_ServiceError(t *testing.T) {
|
|||
mockService := new(MockSocialService)
|
||||
router := setupTestSocialRouter(mockService)
|
||||
|
||||
mockService.On("GetGlobalFeed", mock.Anything, 20, 0).Return(nil, assert.AnError)
|
||||
mockService.On("GetGlobalFeed", mock.Anything, 20, 0, "all", (*uuid.UUID)(nil)).Return(nil, assert.AnError)
|
||||
|
||||
// Execute
|
||||
req, _ := http.NewRequest("GET", "/api/v1/feed", nil)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package services
|
|||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
|
@ -13,8 +14,9 @@ import (
|
|||
|
||||
// NotificationService handles notification operations
|
||||
type NotificationService struct {
|
||||
db *database.Database
|
||||
logger *zap.Logger
|
||||
db *database.Database
|
||||
logger *zap.Logger
|
||||
pushService *PushService // optional, for N1.2 Web Push
|
||||
}
|
||||
|
||||
// Notification represents a notification
|
||||
|
|
@ -37,7 +39,12 @@ func NewNotificationService(db *database.Database, logger *zap.Logger) *Notifica
|
|||
}
|
||||
}
|
||||
|
||||
// CreateNotification creates a new notification
|
||||
// SetPushService injects the push service for Web Push (N1.2)
|
||||
func (ns *NotificationService) SetPushService(ps *PushService) {
|
||||
ns.pushService = ps
|
||||
}
|
||||
|
||||
// CreateNotification creates a new notification and optionally sends Web Push (N1.2)
|
||||
func (ns *NotificationService) CreateNotification(userID uuid.UUID, notificationType, title, content, link string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
|
|
@ -50,6 +57,35 @@ func (ns *NotificationService) CreateNotification(userID uuid.UUID, notification
|
|||
return fmt.Errorf("failed to create notification: %w", err)
|
||||
}
|
||||
|
||||
// N1.2: Send Web Push if enabled and user has subscriptions
|
||||
if ns.pushService != nil {
|
||||
prefs, err := ns.GetPreferences(userID)
|
||||
if err != nil {
|
||||
ns.logger.Warn("failed to get push preferences", zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
var shouldPush bool
|
||||
switch notificationType {
|
||||
case "follow":
|
||||
shouldPush = prefs.PushFollow
|
||||
case "like":
|
||||
shouldPush = prefs.PushLike
|
||||
case "comment":
|
||||
shouldPush = prefs.PushComment
|
||||
case "new_message", "message":
|
||||
shouldPush = prefs.PushMessage
|
||||
case "user_mentioned", "mention":
|
||||
shouldPush = prefs.PushMention
|
||||
default:
|
||||
shouldPush = false
|
||||
}
|
||||
if shouldPush {
|
||||
if err := ns.pushService.SendPushToUser(ctx, userID, title, content, link); err != nil {
|
||||
ns.logger.Warn("failed to send push notification", zap.Error(err), zap.String("user_id", userID.String()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -180,3 +216,53 @@ func (ns *NotificationService) DeleteAllNotifications(userID uuid.UUID) error {
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NotificationPrefs represents notification preferences (N1.3)
|
||||
type NotificationPrefs struct {
|
||||
PushFollow bool `json:"push_follow"`
|
||||
PushLike bool `json:"push_like"`
|
||||
PushComment bool `json:"push_comment"`
|
||||
PushMessage bool `json:"push_message"`
|
||||
PushMention bool `json:"push_mention"`
|
||||
}
|
||||
|
||||
// GetPreferences returns notification preferences for a user
|
||||
func (ns *NotificationService) GetPreferences(userID uuid.UUID) (*NotificationPrefs, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
prefs := &NotificationPrefs{PushFollow: true, PushLike: true, PushComment: true, PushMessage: true, PushMention: true}
|
||||
|
||||
err := ns.db.QueryRowContext(ctx, `
|
||||
SELECT push_follow, push_like, push_comment, push_message, push_mention
|
||||
FROM notification_preferences
|
||||
WHERE user_id = $1
|
||||
`, userID).Scan(&prefs.PushFollow, &prefs.PushLike, &prefs.PushComment, &prefs.PushMessage, &prefs.PushMention)
|
||||
|
||||
if err == nil {
|
||||
return prefs, nil
|
||||
}
|
||||
if err != sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("failed to get preferences: %w", err)
|
||||
}
|
||||
|
||||
return prefs, nil
|
||||
}
|
||||
|
||||
// UpdatePreferences updates notification preferences
|
||||
func (ns *NotificationService) UpdatePreferences(userID uuid.UUID, pushFollow, pushLike, pushComment, pushMessage, pushMention *bool) error {
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := ns.db.ExecContext(ctx, `
|
||||
INSERT INTO notification_preferences (user_id, push_follow, push_like, push_comment, push_message, push_mention, updated_at)
|
||||
VALUES ($1, COALESCE($2, true), COALESCE($3, true), COALESCE($4, true), COALESCE($5, true), COALESCE($6, true), NOW())
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
push_follow = CASE WHEN $2 IS NOT NULL THEN $2 ELSE notification_preferences.push_follow END,
|
||||
push_like = CASE WHEN $3 IS NOT NULL THEN $3 ELSE notification_preferences.push_like END,
|
||||
push_comment = CASE WHEN $4 IS NOT NULL THEN $4 ELSE notification_preferences.push_comment END,
|
||||
push_message = CASE WHEN $5 IS NOT NULL THEN $5 ELSE notification_preferences.push_message END,
|
||||
push_mention = CASE WHEN $6 IS NOT NULL THEN $6 ELSE notification_preferences.push_mention END,
|
||||
updated_at = NOW()
|
||||
`, userID, pushFollow, pushLike, pushComment, pushMessage, pushMention)
|
||||
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
93
veza-backend-api/internal/services/push_service.go
Normal file
93
veza-backend-api/internal/services/push_service.go
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/SherClockHolmes/webpush-go"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PushSubscription represents a Web Push subscription (v0.302 N1)
|
||||
type PushSubscription struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
||||
UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id"`
|
||||
Endpoint string `gorm:"type:text;not null" json:"endpoint"`
|
||||
P256dh string `gorm:"type:text;not null" json:"p256dh"`
|
||||
Auth string `gorm:"type:text;not null" json:"auth"`
|
||||
CreatedAt string `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"`
|
||||
}
|
||||
|
||||
func (PushSubscription) TableName() string {
|
||||
return "push_subscriptions"
|
||||
}
|
||||
|
||||
// PushService handles Web Push subscriptions and sending
|
||||
type PushService struct {
|
||||
db *gorm.DB
|
||||
logger *zap.Logger
|
||||
vapidPublicKey string
|
||||
vapidPrivateKey string
|
||||
}
|
||||
|
||||
// NewPushService creates a new PushService
|
||||
func NewPushService(db *gorm.DB, logger *zap.Logger, vapidPublic, vapidPrivate string) *PushService {
|
||||
return &PushService{
|
||||
db: db,
|
||||
logger: logger,
|
||||
vapidPublicKey: vapidPublic,
|
||||
vapidPrivateKey: vapidPrivate,
|
||||
}
|
||||
}
|
||||
|
||||
// SubscribePush stores a Web Push subscription for a user
|
||||
func (s *PushService) SubscribePush(ctx context.Context, userID uuid.UUID, endpoint, p256dh, auth string) error {
|
||||
sub := &PushSubscription{
|
||||
UserID: userID,
|
||||
Endpoint: endpoint,
|
||||
P256dh: p256dh,
|
||||
Auth: auth,
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Create(sub).Error; err != nil {
|
||||
return fmt.Errorf("failed to store subscription: %w", err)
|
||||
}
|
||||
s.logger.Info("Push subscription stored", zap.String("user_id", userID.String()))
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendPushToUser sends a Web Push notification to all subscriptions of a user
|
||||
func (s *PushService) SendPushToUser(ctx context.Context, userID uuid.UUID, title, body, link string) error {
|
||||
if s.vapidPrivateKey == "" {
|
||||
return nil
|
||||
}
|
||||
var subs []PushSubscription
|
||||
if err := s.db.WithContext(ctx).Where("user_id = ?", userID).Find(&subs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]string{
|
||||
"title": title,
|
||||
"body": body,
|
||||
"link": link,
|
||||
})
|
||||
for _, ps := range subs {
|
||||
subscription := webpush.Subscription{
|
||||
Endpoint: ps.Endpoint,
|
||||
Keys: webpush.Keys{
|
||||
P256dh: ps.P256dh,
|
||||
Auth: ps.Auth,
|
||||
},
|
||||
}
|
||||
_, err := webpush.SendNotification(payload, &subscription, &webpush.Options{
|
||||
VAPIDPublicKey: s.vapidPublicKey,
|
||||
VAPIDPrivateKey: s.vapidPrivateKey,
|
||||
TTL: 30,
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to send push", zap.Error(err), zap.String("user_id", userID.String()))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
13
veza-backend-api/migrations/093_notification_preferences.sql
Normal file
13
veza-backend-api/migrations/093_notification_preferences.sql
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
-- Migration 093: Notification preferences for Web Push (v0.302 Lot N1.3)
|
||||
-- User preferences for which notification types trigger browser push
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notification_preferences (
|
||||
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
push_follow BOOLEAN NOT NULL DEFAULT true,
|
||||
push_like BOOLEAN NOT NULL DEFAULT true,
|
||||
push_comment BOOLEAN NOT NULL DEFAULT true,
|
||||
push_message BOOLEAN NOT NULL DEFAULT true,
|
||||
push_mention BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
Loading…
Reference in a new issue