package admin import ( "context" "errors" "fmt" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) var ErrNotFound = errors.New("not found") var ErrNameTaken = errors.New("name already taken") var ErrSelfDemote = errors.New("cannot remove own admin status") type Store struct { db *pgxpool.Pool } func NewStore(db *pgxpool.Pool) *Store { return &Store{db: db} } // ── Users ───────────────────────────────────────────────────────────────────── func (s *Store) ListUsers(ctx context.Context) ([]*User, error) { rows, err := s.db.Query(ctx, // Die Gruppen kommen als Unterabfrage, nicht als weiterer JOIN: ein JOIN // würde die Zeilen vervielfachen und damit die Summe der Dateigrößen // verfälschen. `SELECT u.id, u.username, u.display_name, u.is_admin, u.quota_bytes, COALESCE(SUM(f.size) FILTER (WHERE f.is_deleted = false), 0) AS used_bytes, COALESCE((SELECT array_agg(g.name ORDER BY g.name) FROM group_members gm JOIN groups g ON g.id = gm.group_id WHERE gm.user_id = u.id), '{}') AS groups, u.created_at FROM users u LEFT JOIN files f ON f.owner_id = u.id GROUP BY u.id ORDER BY u.created_at ASC`, ) if err != nil { return nil, fmt.Errorf("list users: %w", err) } defer rows.Close() users := make([]*User, 0) for rows.Next() { var u User if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.IsAdmin, &u.QuotaBytes, &u.UsedBytes, &u.Groups, &u.CreatedAt); err != nil { return nil, err } users = append(users, &u) } return users, rows.Err() } func (s *Store) GetUser(ctx context.Context, id uuid.UUID) (*User, error) { var u User err := s.db.QueryRow(ctx, `SELECT u.id, u.username, u.display_name, u.is_admin, u.quota_bytes, COALESCE(SUM(f.size) FILTER (WHERE f.is_deleted = false), 0) AS used_bytes, u.created_at FROM users u LEFT JOIN files f ON f.owner_id = u.id WHERE u.id = $1 GROUP BY u.id`, id, ).Scan(&u.ID, &u.Username, &u.DisplayName, &u.IsAdmin, &u.QuotaBytes, &u.UsedBytes, &u.CreatedAt) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } return &u, err } func (s *Store) CreateUser(ctx context.Context, username, displayName, passwordHash string, isAdmin bool, quotaBytes int64) (*User, error) { var u User err := s.db.QueryRow(ctx, `INSERT INTO users (username, display_name, password, is_admin, quota_bytes) VALUES ($1, $2, $3, $4, $5) RETURNING id, username, display_name, is_admin, quota_bytes, 0, created_at`, username, displayName, passwordHash, isAdmin, quotaBytes, ).Scan(&u.ID, &u.Username, &u.DisplayName, &u.IsAdmin, &u.QuotaBytes, &u.UsedBytes, &u.CreatedAt) if err != nil { if isUniqueViolation(err) { return nil, ErrNameTaken } return nil, fmt.Errorf("create user: %w", err) } return &u, nil } func (s *Store) UpdateUser(ctx context.Context, id uuid.UUID, displayName string, isAdmin bool, quotaBytes int64, callerID uuid.UUID) (*User, error) { // Prevent admin from removing their own admin flag. if id == callerID && !isAdmin { return nil, ErrSelfDemote } var u User err := s.db.QueryRow(ctx, `UPDATE users SET display_name=$2, is_admin=$3, quota_bytes=$4 WHERE id=$1 RETURNING id, username, display_name, is_admin, quota_bytes, created_at`, id, displayName, isAdmin, quotaBytes, ).Scan(&u.ID, &u.Username, &u.DisplayName, &u.IsAdmin, &u.QuotaBytes, &u.CreatedAt) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } return &u, err } func (s *Store) ResetPassword(ctx context.Context, id uuid.UUID, newHash string) error { tag, err := s.db.Exec(ctx, `UPDATE users SET password=$2 WHERE id=$1`, id, newHash) if err != nil { return fmt.Errorf("reset password: %w", err) } if tag.RowsAffected() == 0 { return ErrNotFound } return nil } func (s *Store) DeleteUser(ctx context.Context, id, callerID uuid.UUID) error { if id == callerID { return ErrSelfDemote } tag, err := s.db.Exec(ctx, `DELETE FROM users WHERE id=$1`, id) if err != nil { return fmt.Errorf("delete user: %w", err) } if tag.RowsAffected() == 0 { return ErrNotFound } return nil } // ── Groups ──────────────────────────────────────────────────────────────────── func (s *Store) ListGroups(ctx context.Context) ([]*Group, error) { rows, err := s.db.Query(ctx, `SELECT g.id, g.name, g.description, COUNT(gm.user_id) AS member_count, g.created_at FROM groups g LEFT JOIN group_members gm ON gm.group_id = g.id GROUP BY g.id ORDER BY g.name`, ) if err != nil { return nil, fmt.Errorf("list groups: %w", err) } defer rows.Close() groups := make([]*Group, 0) for rows.Next() { var g Group if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.MemberCount, &g.CreatedAt); err != nil { return nil, err } groups = append(groups, &g) } return groups, rows.Err() } func (s *Store) CreateGroup(ctx context.Context, name, description string) (*Group, error) { var g Group err := s.db.QueryRow(ctx, `INSERT INTO groups (name, description) VALUES ($1, $2) RETURNING id, name, description, 0, created_at`, name, description, ).Scan(&g.ID, &g.Name, &g.Description, &g.MemberCount, &g.CreatedAt) if err != nil { if isUniqueViolation(err) { return nil, ErrNameTaken } return nil, fmt.Errorf("create group: %w", err) } return &g, nil } // UpdateGroup ändert Name und Beschreibung einer Gruppe. func (s *Store) UpdateGroup(ctx context.Context, id uuid.UUID, name, description string) (*Group, error) { var g Group err := s.db.QueryRow(ctx, `UPDATE groups SET name=$2, description=$3 WHERE id=$1 RETURNING id, name, description, (SELECT COUNT(*) FROM group_members WHERE group_id = groups.id), created_at`, id, name, description, ).Scan(&g.ID, &g.Name, &g.Description, &g.MemberCount, &g.CreatedAt) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } if err != nil { if isUniqueViolation(err) { return nil, ErrNameTaken } return nil, fmt.Errorf("update group: %w", err) } return &g, nil } func (s *Store) DeleteGroup(ctx context.Context, id uuid.UUID) error { tag, err := s.db.Exec(ctx, `DELETE FROM groups WHERE id=$1`, id) if err != nil { return err } if tag.RowsAffected() == 0 { return ErrNotFound } return nil } func (s *Store) ListGroupMembers(ctx context.Context, groupID uuid.UUID) ([]*GroupMember, error) { rows, err := s.db.Query(ctx, `SELECT u.id, u.username, u.display_name, gm.joined_at FROM group_members gm JOIN users u ON u.id = gm.user_id WHERE gm.group_id = $1 ORDER BY u.username`, groupID, ) if err != nil { return nil, fmt.Errorf("list members: %w", err) } defer rows.Close() members := make([]*GroupMember, 0) for rows.Next() { var m GroupMember if err := rows.Scan(&m.UserID, &m.Username, &m.DisplayName, &m.JoinedAt); err != nil { return nil, err } members = append(members, &m) } return members, rows.Err() } func (s *Store) AddGroupMember(ctx context.Context, groupID, userID uuid.UUID) error { _, err := s.db.Exec(ctx, `INSERT INTO group_members (group_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, groupID, userID, ) return err } func (s *Store) RemoveGroupMember(ctx context.Context, groupID, userID uuid.UUID) error { _, err := s.db.Exec(ctx, `DELETE FROM group_members WHERE group_id=$1 AND user_id=$2`, groupID, userID, ) return err } // ── Settings ────────────────────────────────────────────────────────────────── func (s *Store) GetAllSettings(ctx context.Context) (map[string]string, error) { rows, err := s.db.Query(ctx, `SELECT key, value FROM settings`) if err != nil { return nil, err } defer rows.Close() result := make(map[string]string) for rows.Next() { var k, v string if err := rows.Scan(&k, &v); err != nil { return nil, err } result[k] = v } return result, rows.Err() } func (s *Store) SetSetting(ctx context.Context, key, value string) error { _, err := s.db.Exec(ctx, `INSERT INTO settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value=EXCLUDED.value, updated_at=NOW()`, key, value, ) return err } // ── helpers ─────────────────────────────────────────────────────────────────── func isUniqueViolation(err error) bool { if err == nil { return false } type sqlStater interface{ SQLState() string } var se sqlStater if errors.As(err, &se) { return se.SQLState() == "23505" } return false }