// backend/admin.go package main import ( "context" "errors" "net/http" "strings" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "golang.org/x/crypto/bcrypt" ) type AdminTeam struct { ID string `json:"id"` Name string `json:"name"` Initial string `json:"initial"` Href string `json:"href"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } type AdminUser struct { ID string `json:"id"` Username string `json:"username"` DisplayName string `json:"displayName"` Email string `json:"email"` Avatar string `json:"avatar"` Unit string `json:"unit"` Group string `json:"group"` Rights []string `json:"rights"` Teams []AdminTeam `json:"teams"` Online bool `json:"onlineStatus"` PresenceStatus string `json:"presenceStatus"` LastSeenAt *time.Time `json:"lastSeenAt"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } type AdminUserRequest struct { Username string `json:"username"` DisplayName string `json:"displayName"` Email string `json:"email"` Password string `json:"password"` Avatar string `json:"avatar"` Unit string `json:"unit"` Group string `json:"group"` Rights []string `json:"rights"` TeamIDs []string `json:"teamIds"` } type AdminTeamRequest struct { Name string `json:"name"` Initial string `json:"initial"` Href string `json:"href"` } func normalizeAdminUserInput(input *AdminUserRequest) { input.Username = strings.TrimSpace(input.Username) input.DisplayName = strings.TrimSpace(input.DisplayName) input.Email = strings.TrimSpace(input.Email) input.Password = strings.TrimSpace(input.Password) input.Avatar = strings.TrimSpace(input.Avatar) input.Unit = strings.TrimSpace(input.Unit) input.Group = strings.TrimSpace(input.Group) if input.Group == "" { input.Group = "user" } input.Rights = cleanStringList(input.Rights) input.TeamIDs = cleanStringList(input.TeamIDs) } func ensureAdminUserLookupValues(ctx context.Context, tx pgx.Tx, unit string) error { return ensureOptionalDeviceLookupValue(ctx, tx, "user_units", unit) } func normalizeAdminTeamInput(input *AdminTeamRequest) { input.Name = strings.TrimSpace(input.Name) input.Initial = strings.TrimSpace(input.Initial) input.Href = strings.TrimSpace(input.Href) if input.Initial == "" && input.Name != "" { input.Initial = strings.ToUpper(string([]rune(input.Name)[0])) } if input.Href == "" { input.Href = "#" } } func cleanStringList(values []string) []string { result := []string{} seen := map[string]bool{} for _, value := range values { value = strings.TrimSpace(value) if value == "" || seen[value] { continue } seen[value] = true result = append(result, value) } return result } func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) { rows, err := s.db.Query( r.Context(), ` SELECT id::TEXT, COALESCE(username, ''), COALESCE(display_name, ''), email, COALESCE(avatar, ''), COALESCE(unit, ''), COALESCE(user_group, ''), rights, `+presenceStatusSQL+`, last_seen_at, created_at, updated_at FROM users ORDER BY display_name ASC, email ASC `, ) if err != nil { writeError(w, http.StatusInternalServerError, "Benutzer konnten nicht geladen werden") return } defer rows.Close() users := []AdminUser{} userIndexByID := map[string]int{} for rows.Next() { var user AdminUser if err := rows.Scan( &user.ID, &user.Username, &user.DisplayName, &user.Email, &user.Avatar, &user.Unit, &user.Group, &user.Rights, &user.PresenceStatus, &user.LastSeenAt, &user.CreatedAt, &user.UpdatedAt, ); err != nil { writeError(w, http.StatusInternalServerError, "Benutzer konnten nicht gelesen werden") return } user.Teams = []AdminTeam{} user.Online = user.PresenceStatus == "online" users = append(users, user) userIndexByID[user.ID] = len(users) - 1 } if err := rows.Err(); err != nil { writeError(w, http.StatusInternalServerError, "Benutzer konnten nicht vollständig gelesen werden") return } teamRows, err := s.db.Query( r.Context(), ` SELECT ut.user_id::TEXT, t.id::TEXT, t.name, t.initial, t.href, t.created_at, t.updated_at FROM user_teams ut JOIN teams t ON t.id = ut.team_id ORDER BY t.name ASC `, ) if err != nil { writeError(w, http.StatusInternalServerError, "Team-Zuordnungen konnten nicht geladen werden") return } defer teamRows.Close() for teamRows.Next() { var userID string var team AdminTeam if err := teamRows.Scan( &userID, &team.ID, &team.Name, &team.Initial, &team.Href, &team.CreatedAt, &team.UpdatedAt, ); err != nil { writeError(w, http.StatusInternalServerError, "Team-Zuordnungen konnten nicht gelesen werden") return } userIndex, ok := userIndexByID[userID] if !ok { continue } users[userIndex].Teams = append(users[userIndex].Teams, team) } if err := teamRows.Err(); err != nil { writeError(w, http.StatusInternalServerError, "Team-Zuordnungen konnten nicht vollständig gelesen werden") return } writeJSON(w, http.StatusOK, map[string]any{ "users": users, }) } func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) { var input AdminUserRequest if err := readJSON(r, &input); err != nil { writeError(w, http.StatusBadRequest, "Invalid JSON") return } normalizeAdminUserInput(&input) if input.Email == "" { writeError(w, http.StatusBadRequest, "E-Mail ist erforderlich") return } if input.Password == "" { writeError(w, http.StatusBadRequest, "Passwort ist erforderlich") return } passwordHash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost) if err != nil { writeError(w, http.StatusInternalServerError, "Passwort konnte nicht verarbeitet werden") return } tx, err := s.db.Begin(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "Benutzer konnte nicht erstellt werden") return } defer tx.Rollback(r.Context()) if err := ensureAdminUserLookupValues(r.Context(), tx, input.Unit); err != nil { writeError(w, http.StatusInternalServerError, "Einheit konnte nicht gespeichert werden") return } var userID string err = tx.QueryRow( r.Context(), ` INSERT INTO users ( username, display_name, email, password_hash, avatar, unit, user_group, rights ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id::TEXT `, input.Username, input.DisplayName, input.Email, string(passwordHash), input.Avatar, input.Unit, input.Group, input.Rights, ).Scan(&userID) if err != nil { var pgErr *pgconn.PgError if errors.As(err, &pgErr) && pgErr.Code == "23505" { writeError(w, http.StatusConflict, "Benutzername oder E-Mail existiert bereits") return } writeError(w, http.StatusInternalServerError, "Benutzer konnte nicht erstellt werden") return } if err := replaceUserTeams(r.Context(), tx, userID, input.TeamIDs); err != nil { writeError(w, http.StatusInternalServerError, "Teams konnten nicht zugewiesen werden") return } if err := tx.Commit(r.Context()); err != nil { writeError(w, http.StatusInternalServerError, "Benutzer konnte nicht erstellt werden") return } if createdUser, err := s.getUserByID(r.Context(), userID); err == nil { s.publishUserProfileEvent("user.created", createdUser) } writeJSON(w, http.StatusCreated, map[string]any{ "ok": true, }) } func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) { userID := strings.TrimSpace(r.PathValue("id")) if userID == "" { writeError(w, http.StatusBadRequest, "Benutzer-ID fehlt") return } var input AdminUserRequest if err := readJSON(r, &input); err != nil { writeError(w, http.StatusBadRequest, "Invalid JSON") return } normalizeAdminUserInput(&input) if input.Email == "" { writeError(w, http.StatusBadRequest, "E-Mail ist erforderlich") return } tx, err := s.db.Begin(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "Benutzer konnte nicht aktualisiert werden") return } defer tx.Rollback(r.Context()) if err := ensureAdminUserLookupValues(r.Context(), tx, input.Unit); err != nil { writeError(w, http.StatusInternalServerError, "Einheit konnte nicht gespeichert werden") return } if input.Password != "" { passwordHash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost) if err != nil { writeError(w, http.StatusInternalServerError, "Passwort konnte nicht verarbeitet werden") return } _, err = tx.Exec( r.Context(), ` UPDATE users SET username = $2, display_name = $3, email = $4, password_hash = $5, avatar = $6, unit = $7, user_group = $8, rights = $9, updated_at = now() WHERE id = $1 `, userID, input.Username, input.DisplayName, input.Email, string(passwordHash), input.Avatar, input.Unit, input.Group, input.Rights, ) if err != nil { writeError(w, http.StatusInternalServerError, "Benutzer konnte nicht aktualisiert werden") return } } else { _, err = tx.Exec( r.Context(), ` UPDATE users SET username = $2, display_name = $3, email = $4, avatar = $5, unit = $6, user_group = $7, rights = $8, updated_at = now() WHERE id = $1 `, userID, input.Username, input.DisplayName, input.Email, input.Avatar, input.Unit, input.Group, input.Rights, ) if err != nil { writeError(w, http.StatusInternalServerError, "Benutzer konnte nicht aktualisiert werden") return } } if err := replaceUserTeams(r.Context(), tx, userID, input.TeamIDs); err != nil { writeError(w, http.StatusInternalServerError, "Teams konnten nicht zugewiesen werden") return } if err := tx.Commit(r.Context()); err != nil { writeError(w, http.StatusInternalServerError, "Benutzer konnte nicht aktualisiert werden") return } if updatedUser, err := s.getUserByID(r.Context(), userID); err == nil { s.publishUserProfileEvent("user.updated", updatedUser) } writeJSON(w, http.StatusOK, map[string]any{ "ok": true, }) } func replaceUserTeams(ctx context.Context, tx pgx.Tx, userID string, teamIDs []string) error { if _, err := tx.Exec(ctx, `DELETE FROM user_teams WHERE user_id = $1`, userID); err != nil { return err } for _, teamID := range teamIDs { if _, err := tx.Exec( ctx, ` INSERT INTO user_teams (user_id, team_id) VALUES ($1, $2) ON CONFLICT (user_id, team_id) DO NOTHING `, userID, teamID, ); err != nil { return err } } return nil } func (s *Server) handleAdminListTeams(w http.ResponseWriter, r *http.Request) { rows, err := s.db.Query( r.Context(), ` SELECT id::TEXT, name, initial, href, created_at, updated_at FROM teams ORDER BY name ASC `, ) if err != nil { writeError(w, http.StatusInternalServerError, "Teams konnten nicht geladen werden") return } defer rows.Close() teams := []AdminTeam{} for rows.Next() { var team AdminTeam if err := rows.Scan( &team.ID, &team.Name, &team.Initial, &team.Href, &team.CreatedAt, &team.UpdatedAt, ); err != nil { writeError(w, http.StatusInternalServerError, "Teams konnten nicht gelesen werden") return } teams = append(teams, team) } writeJSON(w, http.StatusOK, map[string]any{ "teams": teams, }) } func (s *Server) handleAdminCreateTeam(w http.ResponseWriter, r *http.Request) { var input AdminTeamRequest if err := readJSON(r, &input); err != nil { writeError(w, http.StatusBadRequest, "Invalid JSON") return } normalizeAdminTeamInput(&input) if input.Name == "" { writeError(w, http.StatusBadRequest, "Teamname ist erforderlich") return } _, err := s.db.Exec( r.Context(), ` INSERT INTO teams (name, initial, href) VALUES ($1, $2, $3) `, input.Name, input.Initial, input.Href, ) if err != nil { writeError(w, http.StatusInternalServerError, "Team konnte nicht erstellt werden") return } writeJSON(w, http.StatusCreated, map[string]any{ "ok": true, }) } func (s *Server) handleAdminUpdateTeam(w http.ResponseWriter, r *http.Request) { teamID := strings.TrimSpace(r.PathValue("id")) if teamID == "" { writeError(w, http.StatusBadRequest, "Team-ID fehlt") return } var input AdminTeamRequest if err := readJSON(r, &input); err != nil { writeError(w, http.StatusBadRequest, "Invalid JSON") return } normalizeAdminTeamInput(&input) if input.Name == "" { writeError(w, http.StatusBadRequest, "Teamname ist erforderlich") return } _, err := s.db.Exec( r.Context(), ` UPDATE teams SET name = $2, initial = $3, href = $4, updated_at = now() WHERE id = $1 `, teamID, input.Name, input.Initial, input.Href, ) if err != nil { writeError(w, http.StatusInternalServerError, "Team konnte nicht aktualisiert werden") return } writeJSON(w, http.StatusOK, map[string]any{ "ok": true, }) }