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{}, + ) +}