teg/backend/feedback.go
2026-06-13 10:15:57 +02:00

416 lines
8.4 KiB
Go

// backend/feedback.go
package main
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
type CreateFeedbackRequest struct {
Message string `json:"message"`
// ClientLog ist das clientseitig erfasste Aktivitäts-/Fehlerprotokoll
// (Array). Client enthält Browser-/Geräte-Metadaten.
ClientLog json.RawMessage `json:"clientLog"`
Client json.RawMessage `json:"client"`
}
const (
maxFeedbackClientLogBytes = 512 * 1024
maxFeedbackClientMetaBytes = 16 * 1024
)
type FeedbackResponse struct {
ID string `json:"id"`
UserID string `json:"userId"`
UserName string `json:"userName"`
UserEmail string `json:"userEmail"`
Message string `json:"message"`
Log json.RawMessage `json:"log"`
CreatedAt time.Time `json:"createdAt"`
}
func (s *Server) handleCreateFeedback(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
var input CreateFeedbackRequest
if err := readJSON(r, &input); err != nil {
s.logUserEvent(r.Context(), UserLogEntry{
User: &user,
Request: r,
Level: UserLogLevelWarning,
Category: "feedback",
Action: "read_request",
Message: "Feedback-Request konnte nicht gelesen werden",
Error: err,
})
writeError(w, http.StatusBadRequest, "Invalid JSON")
return
}
input.Message = strings.TrimSpace(input.Message)
if input.Message == "" {
writeError(w, http.StatusBadRequest, "Feedback darf nicht leer sein")
return
}
if len([]rune(input.Message)) > 5000 {
writeError(w, http.StatusBadRequest, "Feedback darf maximal 5000 Zeichen lang sein")
return
}
var clientActivity any = json.RawMessage("[]")
if len(input.ClientLog) > 0 && len(input.ClientLog) <= maxFeedbackClientLogBytes {
clientActivity = input.ClientLog
}
var clientMeta any
if len(input.Client) > 0 && len(input.Client) <= maxFeedbackClientMetaBytes {
clientMeta = input.Client
}
feedbackLog := map[string]any{
"user": buildUserLogSnapshot(user),
"request": mergeLogFields(
LogFields(buildRequestLogSnapshot(r)),
LogFields{
"contentType": r.Header.Get("Content-Type"),
},
),
"server": map[string]any{
"createdAt": time.Now().UTC().Format(time.RFC3339Nano),
},
"client": clientMeta,
"activity": clientActivity,
}
logJSON, err := json.Marshal(feedbackLog)
if err != nil {
s.logAndWriteUserError(
w,
r,
user,
http.StatusInternalServerError,
"feedback",
"build_feedback_log",
"Feedback-Log konnte nicht erstellt werden",
err,
LogFields{
"messageLength": len([]rune(input.Message)),
},
)
return
}
var feedback FeedbackResponse
err = s.db.QueryRow(
r.Context(),
`
INSERT INTO feedback (
user_id,
message,
log
)
VALUES ($1, $2, $3)
RETURNING
id::TEXT,
user_id::TEXT,
$4::TEXT,
$5::TEXT,
message,
log,
created_at
`,
user.ID,
input.Message,
logJSON,
user.DisplayName,
user.Email,
).Scan(
&feedback.ID,
&feedback.UserID,
&feedback.UserName,
&feedback.UserEmail,
&feedback.Message,
&feedback.Log,
&feedback.CreatedAt,
)
if err != nil {
s.logAndWriteUserError(
w,
r,
user,
http.StatusInternalServerError,
"feedback",
"insert_feedback",
"Feedback konnte nicht gespeichert werden",
err,
LogFields{
"messageLength": len([]rune(input.Message)),
},
)
return
}
s.logUserEvent(r.Context(), UserLogEntry{
User: &user,
Request: r,
Level: UserLogLevelInfo,
Category: "feedback",
Action: "create_feedback",
Message: "Feedback wurde gespeichert",
Details: LogFields{
"feedbackId": feedback.ID,
"messageLength": len([]rune(input.Message)),
},
})
_ = s.notifyAdministratorsAboutFeedback(
r.Context(),
"feedback.created",
"Neues Feedback",
formatText("Neues Feedback von %s.", displayFeedbackUserName(feedback)),
feedback.ID,
false,
map[string]any{
"feedbackId": feedback.ID,
"userId": feedback.UserID,
"userName": feedback.UserName,
"userEmail": feedback.UserEmail,
},
)
writeJSON(w, http.StatusCreated, map[string]any{
"feedback": feedback,
})
}
func (s *Server) handleListFeedback(w http.ResponseWriter, r *http.Request) {
rows, err := s.db.Query(
r.Context(),
`
SELECT
f.id::TEXT,
f.user_id::TEXT,
COALESCE(NULLIF(u.display_name, ''), NULLIF(u.username, ''), u.email, 'Unbekannt') AS user_name,
COALESCE(u.email, '') AS user_email,
f.message,
f.log,
f.created_at
FROM feedback f
LEFT JOIN users u ON u.id = f.user_id
ORDER BY f.created_at DESC
LIMIT 200
`,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Feedback konnte nicht geladen werden")
return
}
defer rows.Close()
feedbackItems := make([]FeedbackResponse, 0)
for rows.Next() {
var feedback FeedbackResponse
if err := rows.Scan(
&feedback.ID,
&feedback.UserID,
&feedback.UserName,
&feedback.UserEmail,
&feedback.Message,
&feedback.Log,
&feedback.CreatedAt,
); err != nil {
writeError(w, http.StatusInternalServerError, "Feedback konnte nicht gelesen werden")
return
}
feedbackItems = append(feedbackItems, feedback)
}
if err := rows.Err(); err != nil {
writeError(w, http.StatusInternalServerError, "Feedback konnte nicht gelesen werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"feedback": feedbackItems,
})
}
func (s *Server) handleDeleteFeedback(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
feedbackID := strings.TrimSpace(r.PathValue("id"))
if feedbackID == "" {
writeError(w, http.StatusBadRequest, "Feedback-ID fehlt")
return
}
var deleted FeedbackResponse
err := s.db.QueryRow(
r.Context(),
`
DELETE FROM feedback f
USING users u
WHERE f.id = $1
AND u.id = f.user_id
RETURNING
f.id::TEXT,
f.user_id::TEXT,
COALESCE(NULLIF(u.display_name, ''), NULLIF(u.username, ''), u.email, 'Unbekannt') AS user_name,
COALESCE(u.email, '') AS user_email,
f.message,
f.log,
f.created_at
`,
feedbackID,
).Scan(
&deleted.ID,
&deleted.UserID,
&deleted.UserName,
&deleted.UserEmail,
&deleted.Message,
&deleted.Log,
&deleted.CreatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "Feedback wurde nicht gefunden")
return
}
if err != nil {
s.logAndWriteUserError(
w,
r,
user,
http.StatusInternalServerError,
"feedback",
"delete_feedback",
"Feedback konnte nicht gelöscht werden",
err,
LogFields{
"feedbackId": feedbackID,
},
)
return
}
s.logUserEvent(r.Context(), UserLogEntry{
User: &user,
Request: r,
Level: UserLogLevelInfo,
Category: "feedback",
Action: "delete_feedback",
Message: "Feedback wurde gelöscht",
Details: LogFields{
"feedbackId": deleted.ID,
"userId": deleted.UserID,
},
})
_ = s.notifyAdministratorsAboutFeedback(
r.Context(),
"feedback.deleted",
"Feedback gelöscht",
formatText("Feedback von %s wurde gelöscht.", displayFeedbackUserName(deleted)),
deleted.ID,
true,
map[string]any{
"feedbackId": deleted.ID,
"userId": deleted.UserID,
"userName": deleted.UserName,
"userEmail": deleted.UserEmail,
},
)
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
})
}
func displayFeedbackUserName(feedback FeedbackResponse) string {
if strings.TrimSpace(feedback.UserName) != "" {
return feedback.UserName
}
if strings.TrimSpace(feedback.UserEmail) != "" {
return feedback.UserEmail
}
return "Unbekannt"
}
func (s *Server) notifyAdministratorsAboutFeedback(
ctx context.Context,
notificationType string,
title string,
message string,
feedbackID string,
visible bool,
data map[string]any,
) error {
adminRows, err := s.db.Query(
ctx,
`
SELECT id::TEXT
FROM users
WHERE 'admin' = ANY(rights)
OR 'administration:read' = ANY(rights)
`,
)
if err != nil {
return err
}
defer adminRows.Close()
dataJSON, err := json.Marshal(data)
if err != nil {
return err
}
for adminRows.Next() {
var adminUserID string
if err := adminRows.Scan(&adminUserID); err != nil {
return err
}
if err := s.createAndPublishNotification(
ctx,
adminUserID,
notificationType,
title,
message,
"feedback",
feedbackID,
dataJSON,
visible,
); err != nil {
return err
}
}
return adminRows.Err()
}