diff --git a/veza-backend-api/go.mod b/veza-backend-api/go.mod index 6a4e0b37f..f8f28699e 100644 --- a/veza-backend-api/go.mod +++ b/veza-backend-api/go.mod @@ -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 diff --git a/veza-backend-api/go.sum b/veza-backend-api/go.sum index 4a47116bb..c2c2bc0f3 100644 --- a/veza-backend-api/go.sum +++ b/veza-backend-api/go.sum @@ -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= diff --git a/veza-backend-api/internal/api/router.go b/veza-backend-api/internal/api/router.go index 67b30201d..6162eb284 100644 --- a/veza-backend-api/internal/api/router.go +++ b/veza-backend-api/internal/api/router.go @@ -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 diff --git a/veza-backend-api/internal/api/routes_core.go b/veza-backend-api/internal/api/routes_core.go index cfd349519..a4d8c3e35 100644 --- a/veza-backend-api/internal/api/routes_core.go +++ b/veza-backend-api/internal/api/routes_core.go @@ -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) diff --git a/veza-backend-api/internal/api/routes_tracks.go b/veza-backend-api/internal/api/routes_tracks.go index bb197667f..d5d481627 100644 --- a/veza-backend-api/internal/api/routes_tracks.go +++ b/veza-backend-api/internal/api/routes_tracks.go @@ -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") { diff --git a/veza-backend-api/internal/api/routes_users.go b/veza-backend-api/internal/api/routes_users.go index 23447d1f6..d913ad884 100644 --- a/veza-backend-api/internal/api/routes_users.go +++ b/veza-backend-api/internal/api/routes_users.go @@ -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") { diff --git a/veza-backend-api/internal/handlers/notification_handlers.go b/veza-backend-api/internal/handlers/notification_handlers.go index a60fb5ba6..b0ae59135 100644 --- a/veza-backend-api/internal/handlers/notification_handlers.go +++ b/veza-backend-api/internal/handlers/notification_handlers.go @@ -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"}) +} diff --git a/veza-backend-api/internal/handlers/notification_handlers_test.go b/veza-backend-api/internal/handlers/notification_handlers_test.go index da44b2af4..7b0836c00 100644 --- a/veza-backend-api/internal/handlers/notification_handlers_test.go +++ b/veza-backend-api/internal/handlers/notification_handlers_test.go @@ -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) diff --git a/veza-backend-api/internal/handlers/social_test.go b/veza-backend-api/internal/handlers/social_test.go index 29bae334d..c212ed73c 100644 --- a/veza-backend-api/internal/handlers/social_test.go +++ b/veza-backend-api/internal/handlers/social_test.go @@ -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) diff --git a/veza-backend-api/internal/services/notification_service.go b/veza-backend-api/internal/services/notification_service.go index 91a745090..b5d38cc54 100644 --- a/veza-backend-api/internal/services/notification_service.go +++ b/veza-backend-api/internal/services/notification_service.go @@ -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 +} diff --git a/veza-backend-api/internal/services/push_service.go b/veza-backend-api/internal/services/push_service.go new file mode 100644 index 000000000..51fad4f09 --- /dev/null +++ b/veza-backend-api/internal/services/push_service.go @@ -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 +} diff --git a/veza-backend-api/migrations/093_notification_preferences.sql b/veza-backend-api/migrations/093_notification_preferences.sql new file mode 100644 index 000000000..60e4e6132 --- /dev/null +++ b/veza-backend-api/migrations/093_notification_preferences.sql @@ -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() +);