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:
senke 2026-02-21 16:41:39 +01:00
parent d2a55b405e
commit 49e3122e78
12 changed files with 386 additions and 23 deletions

View file

@ -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

View file

@ -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=

View file

@ -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

View file

@ -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)

View file

@ -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")
{

View file

@ -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")
{

View file

@ -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"})
}

View file

@ -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)

View file

@ -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)

View file

@ -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
}

View 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
}

View 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()
);