// 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"` } 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 } 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), }, } 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, true, 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, false, 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 } var notification Notification err = s.db.QueryRow( ctx, ` INSERT INTO notifications ( user_id, type, title, message, visible, entity_type, entity_id, data ) VALUES ( $1, $2, $3, $4, true, 'feedback', $5, $6, $7 ) RETURNING id::TEXT, user_id::TEXT, type, title, message, entity_type, COALESCE(entity_id::TEXT, ''), data, visible, read_at, created_at `, adminUserID, notificationType, title, message, visible, feedbackID, dataJSON, ).Scan( ¬ification.ID, ¬ification.UserID, ¬ification.Type, ¬ification.Title, ¬ification.Message, ¬ification.EntityType, ¬ification.EntityID, ¬ification.Data, ¬ification.Visible, ¬ification.ReadAt, ¬ification.CreatedAt, ) if err != nil { return err } notificationsBroker.publish(notification) } return adminRows.Err() }