416 lines
8.4 KiB
Go
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()
|
|
}
|