560 lines
10 KiB
Go
560 lines
10 KiB
Go
// backend\notifications.go
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
type Notification struct {
|
|
ID string `json:"id"`
|
|
UserID string `json:"userId"`
|
|
Type string `json:"type"`
|
|
Title string `json:"title"`
|
|
Message string `json:"message"`
|
|
EntityType string `json:"entityType"`
|
|
EntityID string `json:"entityId"`
|
|
Data json.RawMessage `json:"data"`
|
|
Visible bool `json:"visible"`
|
|
ReadAt *time.Time `json:"readAt"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
}
|
|
|
|
type notificationClient struct {
|
|
userID string
|
|
ch chan Notification
|
|
}
|
|
|
|
type notificationBroker struct {
|
|
mu sync.RWMutex
|
|
clients map[*notificationClient]struct{}
|
|
}
|
|
|
|
var notificationsBroker = ¬ificationBroker{
|
|
clients: map[*notificationClient]struct{}{},
|
|
}
|
|
|
|
func (b *notificationBroker) subscribe(userID string) *notificationClient {
|
|
client := ¬ificationClient{
|
|
userID: userID,
|
|
ch: make(chan Notification, 16),
|
|
}
|
|
|
|
b.mu.Lock()
|
|
b.clients[client] = struct{}{}
|
|
b.mu.Unlock()
|
|
|
|
return client
|
|
}
|
|
|
|
func (b *notificationBroker) unsubscribe(client *notificationClient) {
|
|
b.mu.Lock()
|
|
delete(b.clients, client)
|
|
close(client.ch)
|
|
b.mu.Unlock()
|
|
}
|
|
|
|
func (b *notificationBroker) publish(notification Notification) {
|
|
b.mu.RLock()
|
|
defer b.mu.RUnlock()
|
|
|
|
for client := range b.clients {
|
|
if client.userID != notification.UserID {
|
|
continue
|
|
}
|
|
|
|
select {
|
|
case client.ch <- notification:
|
|
default:
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleNotificationEvents(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := userFromContext(r.Context())
|
|
if !ok {
|
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("Connection", "keep-alive")
|
|
w.Header().Set("X-Accel-Buffering", "no")
|
|
|
|
flusher, ok := w.(http.Flusher)
|
|
if !ok {
|
|
writeError(w, http.StatusInternalServerError, "Streaming wird nicht unterstützt")
|
|
return
|
|
}
|
|
|
|
client := notificationsBroker.subscribe(user.ID)
|
|
defer notificationsBroker.unsubscribe(client)
|
|
|
|
fmt.Fprintf(w, "event: connected\n")
|
|
fmt.Fprintf(w, "data: {}\n\n")
|
|
flusher.Flush()
|
|
|
|
for {
|
|
select {
|
|
case <-r.Context().Done():
|
|
return
|
|
|
|
case notification := <-client.ch:
|
|
payload, err := json.Marshal(notification)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
eventName := "notification"
|
|
|
|
if !notification.Visible {
|
|
switch notification.EntityType {
|
|
case "operation":
|
|
eventName = "operation-event"
|
|
case "device":
|
|
eventName = "device-event"
|
|
default:
|
|
eventName = "notification-event"
|
|
}
|
|
}
|
|
|
|
fmt.Fprintf(w, "event: %s\n", eventName)
|
|
fmt.Fprintf(w, "data: %s\n\n", payload)
|
|
flusher.Flush()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := userFromContext(r.Context())
|
|
if !ok {
|
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
|
return
|
|
}
|
|
|
|
rows, err := s.db.Query(
|
|
r.Context(),
|
|
`
|
|
SELECT
|
|
id,
|
|
user_id::TEXT,
|
|
type,
|
|
title,
|
|
message,
|
|
entity_type,
|
|
COALESCE(entity_id::TEXT, ''),
|
|
data,
|
|
read_at,
|
|
created_at
|
|
FROM notifications
|
|
WHERE user_id = $1
|
|
AND visible = true
|
|
ORDER BY created_at DESC
|
|
LIMIT 50
|
|
`,
|
|
user.ID,
|
|
)
|
|
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "Benachrichtigungen konnten nicht geladen werden")
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
notifications := []Notification{}
|
|
|
|
for rows.Next() {
|
|
var notification Notification
|
|
var readAt sql.NullTime
|
|
var data []byte
|
|
|
|
if err := rows.Scan(
|
|
¬ification.ID,
|
|
¬ification.UserID,
|
|
¬ification.Type,
|
|
¬ification.Title,
|
|
¬ification.Message,
|
|
¬ification.EntityType,
|
|
¬ification.EntityID,
|
|
&data,
|
|
&readAt,
|
|
¬ification.CreatedAt,
|
|
); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "Benachrichtigungen konnten nicht gelesen werden")
|
|
return
|
|
}
|
|
|
|
if readAt.Valid {
|
|
notification.ReadAt = &readAt.Time
|
|
}
|
|
|
|
notification.Data = json.RawMessage(data)
|
|
notifications = append(notifications, notification)
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"notifications": notifications,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleMarkNotificationRead(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := userFromContext(r.Context())
|
|
if !ok {
|
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
|
return
|
|
}
|
|
|
|
notificationID := strings.TrimSpace(r.PathValue("id"))
|
|
if notificationID == "" {
|
|
writeError(w, http.StatusBadRequest, "Benachrichtigungs-ID fehlt")
|
|
return
|
|
}
|
|
|
|
_, err := s.db.Exec(
|
|
r.Context(),
|
|
`
|
|
UPDATE notifications
|
|
SET read_at = COALESCE(read_at, now())
|
|
WHERE id = $1 AND user_id = $2
|
|
`,
|
|
notificationID,
|
|
user.ID,
|
|
)
|
|
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "Benachrichtigung konnte nicht aktualisiert werden")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"ok": true,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleMarkAllNotificationsRead(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := userFromContext(r.Context())
|
|
if !ok {
|
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
|
return
|
|
}
|
|
|
|
_, err := s.db.Exec(
|
|
r.Context(),
|
|
`
|
|
UPDATE notifications
|
|
SET read_at = COALESCE(read_at, now())
|
|
WHERE user_id = $1 AND read_at IS NULL
|
|
`,
|
|
user.ID,
|
|
)
|
|
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "Benachrichtigungen konnten nicht aktualisiert werden")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"ok": true,
|
|
})
|
|
}
|
|
|
|
func (s *Server) createOperationNotification(
|
|
ctx context.Context,
|
|
actor User,
|
|
operation Operation,
|
|
notificationType string,
|
|
title string,
|
|
message string,
|
|
data map[string]any,
|
|
) error {
|
|
dataJSON, err := json.Marshal(data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
rows, err := s.db.Query(
|
|
ctx,
|
|
`
|
|
SELECT id
|
|
FROM users
|
|
WHERE id <> $1
|
|
`,
|
|
actor.ID,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close()
|
|
|
|
recipientIDs := []string{}
|
|
|
|
for rows.Next() {
|
|
var userID string
|
|
if err := rows.Scan(&userID); err != nil {
|
|
return err
|
|
}
|
|
|
|
recipientIDs = append(recipientIDs, userID)
|
|
}
|
|
|
|
for _, recipientID := range recipientIDs {
|
|
var notification Notification
|
|
var rawData []byte
|
|
|
|
err := s.db.QueryRow(
|
|
ctx,
|
|
`
|
|
INSERT INTO notifications (
|
|
user_id,
|
|
type,
|
|
title,
|
|
message,
|
|
entity_type,
|
|
entity_id,
|
|
data
|
|
)
|
|
VALUES ($1, $2, $3, $4, 'operation', $5, $6::JSONB)
|
|
RETURNING
|
|
id,
|
|
user_id::TEXT,
|
|
type,
|
|
title,
|
|
message,
|
|
entity_type,
|
|
COALESCE(entity_id::TEXT, ''),
|
|
data,
|
|
created_at
|
|
`,
|
|
recipientID,
|
|
notificationType,
|
|
title,
|
|
message,
|
|
operation.ID,
|
|
string(dataJSON),
|
|
).Scan(
|
|
¬ification.ID,
|
|
¬ification.UserID,
|
|
¬ification.Type,
|
|
¬ification.Title,
|
|
¬ification.Message,
|
|
¬ification.EntityType,
|
|
¬ification.EntityID,
|
|
&rawData,
|
|
¬ification.CreatedAt,
|
|
)
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
notification.Data = json.RawMessage(rawData)
|
|
notificationsBroker.publish(notification)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) createOperationJournalEvent(
|
|
ctx context.Context,
|
|
actor User,
|
|
operation Operation,
|
|
data map[string]any,
|
|
) error {
|
|
dataJSON, err := json.Marshal(data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
rows, err := s.db.Query(
|
|
ctx,
|
|
`
|
|
SELECT id
|
|
FROM users
|
|
WHERE id <> $1
|
|
`,
|
|
actor.ID,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var recipientID string
|
|
if err := rows.Scan(&recipientID); err != nil {
|
|
return err
|
|
}
|
|
|
|
var notification Notification
|
|
var rawData []byte
|
|
|
|
err := s.db.QueryRow(
|
|
ctx,
|
|
`
|
|
INSERT INTO notifications (
|
|
user_id,
|
|
type,
|
|
title,
|
|
message,
|
|
entity_type,
|
|
entity_id,
|
|
data,
|
|
visible
|
|
)
|
|
VALUES (
|
|
$1,
|
|
'operation.journal',
|
|
'Journal aktualisiert',
|
|
'Ein Journal-Eintrag wurde aktualisiert.',
|
|
'operation',
|
|
$2,
|
|
$3::JSONB,
|
|
false
|
|
)
|
|
RETURNING
|
|
id,
|
|
user_id::TEXT,
|
|
type,
|
|
title,
|
|
message,
|
|
entity_type,
|
|
COALESCE(entity_id::TEXT, ''),
|
|
data,
|
|
visible,
|
|
created_at
|
|
`,
|
|
recipientID,
|
|
operation.ID,
|
|
string(dataJSON),
|
|
).Scan(
|
|
¬ification.ID,
|
|
¬ification.UserID,
|
|
¬ification.Type,
|
|
¬ification.Title,
|
|
¬ification.Message,
|
|
¬ification.EntityType,
|
|
¬ification.EntityID,
|
|
&rawData,
|
|
¬ification.Visible,
|
|
¬ification.CreatedAt,
|
|
)
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
notification.Data = json.RawMessage(rawData)
|
|
notificationsBroker.publish(notification)
|
|
}
|
|
|
|
return rows.Err()
|
|
}
|
|
|
|
func (s *Server) createDeviceJournalEvent(
|
|
ctx context.Context,
|
|
actor User,
|
|
device Device,
|
|
data map[string]any,
|
|
) error {
|
|
dataJSON, err := json.Marshal(data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
rows, err := s.db.Query(
|
|
ctx,
|
|
`
|
|
SELECT id
|
|
FROM users
|
|
WHERE id <> $1
|
|
AND (
|
|
'admin' = ANY(rights)
|
|
OR 'devices:read' = ANY(rights)
|
|
)
|
|
`,
|
|
actor.ID,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var recipientID string
|
|
if err := rows.Scan(&recipientID); err != nil {
|
|
return err
|
|
}
|
|
|
|
var notification Notification
|
|
var rawData []byte
|
|
|
|
err := s.db.QueryRow(
|
|
ctx,
|
|
`
|
|
INSERT INTO notifications (
|
|
user_id,
|
|
type,
|
|
title,
|
|
message,
|
|
entity_type,
|
|
entity_id,
|
|
data,
|
|
visible
|
|
)
|
|
VALUES (
|
|
$1,
|
|
'device.journal',
|
|
'Geräte-Journal aktualisiert',
|
|
'Ein Geräte-Journal-Eintrag wurde hinzugefügt.',
|
|
'device',
|
|
$2,
|
|
$3::JSONB,
|
|
false
|
|
)
|
|
RETURNING
|
|
id,
|
|
user_id::TEXT,
|
|
type,
|
|
title,
|
|
message,
|
|
entity_type,
|
|
COALESCE(entity_id::TEXT, ''),
|
|
data,
|
|
visible,
|
|
created_at
|
|
`,
|
|
recipientID,
|
|
device.ID,
|
|
string(dataJSON),
|
|
).Scan(
|
|
¬ification.ID,
|
|
¬ification.UserID,
|
|
¬ification.Type,
|
|
¬ification.Title,
|
|
¬ification.Message,
|
|
¬ification.EntityType,
|
|
¬ification.EntityID,
|
|
&rawData,
|
|
¬ification.Visible,
|
|
¬ification.CreatedAt,
|
|
)
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
notification.Data = json.RawMessage(rawData)
|
|
notificationsBroker.publish(notification)
|
|
}
|
|
|
|
return rows.Err()
|
|
}
|