package api import ( "fmt" "net/http" "strings" "time" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgtype" "git.omukk.dev/wrenn/wrenn/pkg/audit" "git.omukk.dev/wrenn/wrenn/pkg/auth" "git.omukk.dev/wrenn/wrenn/pkg/db" "git.omukk.dev/wrenn/wrenn/pkg/id" "git.omukk.dev/wrenn/wrenn/pkg/service" ) type usersHandler struct { db *db.Queries svc *service.UserService audit *audit.AuditLogger } func newUsersHandler(db *db.Queries, svc *service.UserService, al *audit.AuditLogger) *usersHandler { return &usersHandler{db: db, svc: svc, audit: al} } // Search handles GET /v1/users/search?email= // Returns up to 10 users whose email starts with the given prefix. // The prefix must be at least 3 characters long and contain "@". func (h *usersHandler) Search(w http.ResponseWriter, r *http.Request) { auth.MustFromContext(r.Context()) // ensure authenticated prefix := strings.TrimSpace(r.URL.Query().Get("email")) if len(prefix) < 3 || !strings.Contains(prefix, "@") { writeError(w, http.StatusBadRequest, "invalid_request", "email prefix must be at least 3 characters and contain '@'") return } // Escape LIKE metacharacters to prevent pattern injection. escaped := strings.NewReplacer("%", "\\%", "_", "\\_").Replace(prefix) results, err := h.db.SearchUsersByEmailPrefix(r.Context(), pgtype.Text{String: escaped, Valid: true}) if err != nil { writeError(w, http.StatusInternalServerError, "internal", "search failed") return } type userResult struct { UserID string `json:"user_id"` Email string `json:"email"` } resp := make([]userResult, len(results)) for i, u := range results { resp[i] = userResult{UserID: id.FormatUserID(u.ID), Email: u.Email} } writeJSON(w, http.StatusOK, resp) } // AdminListUsers handles GET /v1/admin/users?page=1 // Returns a paginated list of all users with team counts. func (h *usersHandler) AdminListUsers(w http.ResponseWriter, r *http.Request) { page := 1 if p := r.URL.Query().Get("page"); p != "" { if _, err := fmt.Sscanf(p, "%d", &page); err != nil || page < 1 { page = 1 } } const perPage = 100 offset := int32((page - 1) * perPage) users, total, err := h.svc.AdminListUsers(r.Context(), perPage, offset) if err != nil { status, code, msg := serviceErrToHTTP(err) writeError(w, status, code, msg) return } type adminUserResponse struct { ID string `json:"id"` Email string `json:"email"` Name string `json:"name"` IsAdmin bool `json:"is_admin"` Status string `json:"status"` CreatedAt string `json:"created_at"` TeamsJoined int32 `json:"teams_joined"` TeamsOwned int32 `json:"teams_owned"` } resp := make([]adminUserResponse, len(users)) for i, u := range users { resp[i] = adminUserResponse{ ID: id.FormatUserID(u.ID), Email: u.Email, Name: u.Name, IsAdmin: u.IsAdmin, Status: u.Status, CreatedAt: u.CreatedAt.Format(time.RFC3339), TeamsJoined: u.TeamsJoined, TeamsOwned: u.TeamsOwned, } } totalPages := (total + perPage - 1) / perPage writeJSON(w, http.StatusOK, map[string]any{ "users": resp, "total": total, "page": page, "per_page": perPage, "total_pages": totalPages, }) } // SetUserActive handles PUT /v1/admin/users/{id}/active // Enables or disables a user account. Admins cannot deactivate themselves. func (h *usersHandler) SetUserActive(w http.ResponseWriter, r *http.Request) { ac := auth.MustFromContext(r.Context()) userIDStr := chi.URLParam(r, "id") userID, err := id.ParseUserID(userIDStr) if err != nil { writeError(w, http.StatusBadRequest, "invalid_request", "invalid user ID") return } var req struct { Active bool `json:"active"` } if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body") return } if ac.UserID == userID && !req.Active { writeError(w, http.StatusBadRequest, "invalid_request", "cannot deactivate your own account") return } newStatus := "active" if !req.Active { newStatus = "disabled" } // Look up user email for audit log before changing status. user, err := h.db.GetUserByID(r.Context(), userID) if err != nil { writeError(w, http.StatusNotFound, "not_found", "user not found") return } if err := h.svc.SetUserStatus(r.Context(), userID, newStatus); err != nil { httpStatus, code, msg := serviceErrToHTTP(err) writeError(w, httpStatus, code, msg) return } if req.Active { h.audit.LogUserActivate(r.Context(), ac, userID, user.Email) } else { h.audit.LogUserDeactivate(r.Context(), ac, userID, user.Email) } w.WriteHeader(http.StatusNoContent) } // SetUserAdmin handles PUT /v1/admin/users/{id}/admin // Grants or revokes platform admin status. Cannot remove the last admin. func (h *usersHandler) SetUserAdmin(w http.ResponseWriter, r *http.Request) { ac := auth.MustFromContext(r.Context()) userIDStr := chi.URLParam(r, "id") userID, err := id.ParseUserID(userIDStr) if err != nil { writeError(w, http.StatusBadRequest, "invalid_request", "invalid user ID") return } var req struct { Admin bool `json:"admin"` } if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body") return } user, err := h.db.GetUserByID(r.Context(), userID) if err != nil { writeError(w, http.StatusNotFound, "not_found", "user not found") return } if user.IsAdmin == req.Admin { w.WriteHeader(http.StatusNoContent) return } if req.Admin { if err := h.db.SetUserAdmin(r.Context(), db.SetUserAdminParams{ ID: userID, IsAdmin: true, }); err != nil { writeError(w, http.StatusInternalServerError, "internal", "failed to update admin status") return } h.audit.LogUserGrantAdmin(r.Context(), ac, userID, user.Email) } else { affected, err := h.db.RevokeUserAdmin(r.Context(), userID) if err != nil { writeError(w, http.StatusInternalServerError, "internal", "failed to update admin status") return } if affected == 0 { writeError(w, http.StatusBadRequest, "invalid_request", "cannot remove the last admin") return } h.audit.LogUserRevokeAdmin(r.Context(), ac, userID, user.Email) } w.WriteHeader(http.StatusNoContent) }