teg/backend/admin.go
2026-06-15 20:59:44 +02:00

745 lines
16 KiB
Go

// 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"`
ConversationID string `json:"conversationId"`
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
}
}
if _, err := tx.Exec(
ctx,
`
INSERT INTO conversations (type, name, team_id)
SELECT 'group', t.name, t.id
FROM teams t
JOIN user_teams ut
ON ut.team_id = t.id
AND ut.user_id = $1
ON CONFLICT (team_id) WHERE team_id IS NOT NULL
DO UPDATE SET
type = 'group',
name = EXCLUDED.name,
created_by = NULL,
updated_at = now()
`,
userID,
); err != nil {
return err
}
if _, err := tx.Exec(
ctx,
`
DELETE FROM conversation_members cm
USING conversations c
WHERE cm.conversation_id = c.id
AND cm.user_id = $1
AND c.team_id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM user_teams ut
WHERE ut.user_id = cm.user_id
AND ut.team_id = c.team_id
)
`,
userID,
); err != nil {
return err
}
if _, err := tx.Exec(
ctx,
`
INSERT INTO conversation_members (conversation_id, user_id)
SELECT c.id, $1
FROM conversations c
JOIN user_teams ut
ON ut.team_id = c.team_id
AND ut.user_id = $1
WHERE c.team_id IS NOT NULL
ON CONFLICT (conversation_id, user_id) DO NOTHING
`,
userID,
); err != nil {
return err
}
return nil
}
func (s *Server) handleAdminListTeams(w http.ResponseWriter, r *http.Request) {
if err := s.ensureTeamGroupConversations(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "Team-Gruppenchats konnten nicht vorbereitet werden")
return
}
rows, err := s.db.Query(
r.Context(),
`
SELECT
t.id::TEXT,
t.name,
t.initial,
t.href,
COALESCE(c.id::TEXT, ''),
t.created_at,
t.updated_at
FROM teams t
LEFT JOIN conversations c
ON c.team_id = t.id
AND c.type = 'group'
ORDER BY t.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.ConversationID,
&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
}
tx, err := s.db.Begin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "Team konnte nicht erstellt werden")
return
}
defer tx.Rollback(r.Context())
var teamID string
if err := tx.QueryRow(
r.Context(),
`
INSERT INTO teams (name, initial, href)
VALUES ($1, $2, $3)
RETURNING id::TEXT
`,
input.Name,
input.Initial,
input.Href,
).Scan(&teamID); err != nil {
writeError(w, http.StatusInternalServerError, "Team konnte nicht erstellt werden")
return
}
var conversationID string
if err := tx.QueryRow(
r.Context(),
`
INSERT INTO conversations (type, name, team_id)
VALUES ('group', $1, $2)
RETURNING id::TEXT
`,
input.Name,
teamID,
).Scan(&conversationID); err != nil {
writeError(w, http.StatusInternalServerError, "Team-Gruppenchat konnte nicht erstellt werden")
return
}
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "Team konnte nicht gespeichert werden")
return
}
writeJSON(w, http.StatusCreated, map[string]any{
"ok": true,
"id": teamID,
"conversationId": conversationID,
})
}
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
}
tx, err := s.db.Begin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "Team konnte nicht aktualisiert werden")
return
}
defer tx.Rollback(r.Context())
commandTag, err := tx.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
}
if commandTag.RowsAffected() == 0 {
writeError(w, http.StatusNotFound, "Team wurde nicht gefunden")
return
}
var conversationID string
if err := tx.QueryRow(
r.Context(),
`
INSERT INTO conversations (type, name, team_id)
VALUES ('group', $1, $2)
ON CONFLICT (team_id) WHERE team_id IS NOT NULL
DO UPDATE SET
type = 'group',
name = EXCLUDED.name,
created_by = NULL,
updated_at = now()
RETURNING id::TEXT
`,
input.Name,
teamID,
).Scan(&conversationID); err != nil {
writeError(w, http.StatusInternalServerError, "Team-Gruppenchat konnte nicht aktualisiert werden")
return
}
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "Team konnte nicht gespeichert werden")
return
}
s.publishConversationUpdate(r.Context(), conversationID)
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"conversationId": conversationID,
})
}