diff --git a/pkg/notifications/database.go b/pkg/notifications/database.go
index 83fb86874..b007d8a54 100644
--- a/pkg/notifications/database.go
+++ b/pkg/notifications/database.go
@@ -19,6 +19,9 @@ package notifications
import (
"time"
+ "code.vikunja.io/api/pkg/events"
+ "code.vikunja.io/api/pkg/log"
+
"xorm.io/xorm"
)
@@ -43,6 +46,18 @@ type DatabaseNotification struct {
Created time.Time `xorm:"created not null" json:"created"`
}
+// AfterInsert is called by XORM after the row is inserted. For transactional
+// sessions this runs during Commit(), guaranteeing the row is persisted before
+// the event fires.
+func (d *DatabaseNotification) AfterInsert() {
+ if err := events.Dispatch(&NotificationCreatedEvent{
+ NotificationID: d.ID,
+ UserID: d.NotifiableID,
+ }); err != nil {
+ log.Errorf("Failed to dispatch notification created event for notification %d: %v", d.ID, err)
+ }
+}
+
// TableName resolves to a better table name for notifications
func (d *DatabaseNotification) TableName() string {
return "notifications"
@@ -67,6 +82,19 @@ func GetNotificationsForUser(s *xorm.Session, notifiableID int64, limit, start i
return notifications, len(notifications), total, err
}
+// GetNotificationByID returns a single notification by its ID.
+func GetNotificationByID(s *xorm.Session, id int64) (*DatabaseNotification, error) {
+ n := &DatabaseNotification{}
+ has, err := s.ID(id).Get(n)
+ if err != nil {
+ return nil, err
+ }
+ if !has {
+ return nil, nil
+ }
+ return n, nil
+}
+
func GetNotificationsForNameAndUser(s *xorm.Session, notifiableID int64, event string, subjectID int64) (notifications []*DatabaseNotification, err error) {
notifications = []*DatabaseNotification{}
err = s.Where("notifiable_id = ? AND name = ? AND subject_id = ?", notifiableID, event, subjectID).
diff --git a/pkg/notifications/events.go b/pkg/notifications/events.go
new file mode 100644
index 000000000..a65fd85ca
--- /dev/null
+++ b/pkg/notifications/events.go
@@ -0,0 +1,33 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package notifications
+
+import "code.vikunja.io/api/pkg/events"
+
+// NotificationCreatedEvent is dispatched after a notification is committed to the database.
+// The listener reloads the full record from the DB to get accurate timestamps.
+type NotificationCreatedEvent struct {
+ NotificationID int64 `json:"notification_id"`
+ UserID int64 `json:"user_id"`
+}
+
+// Name returns the event name.
+func (n *NotificationCreatedEvent) Name() string {
+ return "notification.created"
+}
+
+var _ events.Event = (*NotificationCreatedEvent)(nil)
diff --git a/pkg/notifications/main_test.go b/pkg/notifications/main_test.go
index 89d1cd2fd..14949adc4 100644
--- a/pkg/notifications/main_test.go
+++ b/pkg/notifications/main_test.go
@@ -22,6 +22,7 @@ import (
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/i18n"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/mail"
@@ -54,5 +55,6 @@ func TestMain(m *testing.M) {
SetupTests()
mail.Fake()
+ events.Fake()
os.Exit(m.Run())
}
diff --git a/pkg/notifications/notification.go b/pkg/notifications/notification.go
index 7f0e1faef..d50735793 100644
--- a/pkg/notifications/notification.go
+++ b/pkg/notifications/notification.go
@@ -118,7 +118,7 @@ func notifyDB(notifiable Notifiable, notification Notification, existingSession
dbNotification := &DatabaseNotification{
NotifiableID: notifiable.RouteForDB(),
- Notification: content,
+ Notification: json.RawMessage(content),
Name: notification.Name(),
}
diff --git a/pkg/websocket/listener.go b/pkg/websocket/listener.go
new file mode 100644
index 000000000..3250d1c45
--- /dev/null
+++ b/pkg/websocket/listener.go
@@ -0,0 +1,76 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package websocket
+
+import (
+ "encoding/json"
+
+ "code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/events"
+ "code.vikunja.io/api/pkg/log"
+ "code.vikunja.io/api/pkg/notifications"
+
+ "github.com/ThreeDotsLabs/watermill/message"
+)
+
+// NotificationListener pushes new notifications to WebSocket clients.
+type NotificationListener struct{}
+
+// Name returns the listener name.
+func (n *NotificationListener) Name() string {
+ return "websocket.notification.push"
+}
+
+// Handle processes a notification created event, reloads the notification
+// from the database (to get accurate timestamps), and pushes it to the
+// relevant WebSocket connections.
+func (n *NotificationListener) Handle(msg *message.Message) error {
+ var event notifications.NotificationCreatedEvent
+ if err := json.Unmarshal(msg.Payload, &event); err != nil {
+ return err
+ }
+
+ hub := GetHub()
+ if hub == nil {
+ log.Warningf("WebSocket: hub not initialized, skipping notification push")
+ return nil
+ }
+
+ s := db.NewSession()
+ defer s.Close()
+
+ dbNotification, err := notifications.GetNotificationByID(s, event.NotificationID)
+ if err != nil {
+ log.Errorf("WebSocket: failed to load notification %d: %v", event.NotificationID, err)
+ return nil
+ }
+ if dbNotification == nil {
+ log.Warningf("WebSocket: notification %d not found, skipping push", event.NotificationID)
+ return nil
+ }
+
+ hub.PublishForUser(event.UserID, "notification.created", dbNotification)
+ return nil
+}
+
+// RegisterListeners registers WebSocket event listeners.
+func RegisterListeners() {
+ events.RegisterListener(
+ (¬ifications.NotificationCreatedEvent{}).Name(),
+ &NotificationListener{},
+ )
+}