package admin import ( "database/sql" "encoding/json" "net/http" "time" "github.com/go-chi/chi/v5" "go.uber.org/zap" "golang.org/x/crypto/bcrypt" "github.com/veylant/ia-gateway/internal/apierror" "github.com/veylant/ia-gateway/internal/middleware" ) // User represents a managed user stored in the users table. type User struct { ID string `json:"id"` TenantID string `json:"tenant_id"` Email string `json:"email"` Name string `json:"name"` Department string `json:"department"` Role string `json:"role"` IsActive bool `json:"is_active"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } type createUserRequest struct { Email string `json:"email"` Name string `json:"name"` Department string `json:"department"` Role string `json:"role"` IsActive *bool `json:"is_active"` Password string `json:"password"` // optional; bcrypt-hashed before storage } // userStore wraps a *sql.DB to perform user CRUD operations. type userStore struct { db *sql.DB logger *zap.Logger } func newUserStore(db *sql.DB, logger *zap.Logger) *userStore { return &userStore{db: db, logger: logger} } func (s *userStore) list(tenantID string) ([]User, error) { rows, err := s.db.Query( `SELECT id, tenant_id, email, name, COALESCE(department,''), role, is_active, created_at, updated_at FROM users WHERE tenant_id = $1 ORDER BY created_at DESC`, tenantID) if err != nil { return nil, err } defer rows.Close() var users []User for rows.Next() { var u User if err := rows.Scan(&u.ID, &u.TenantID, &u.Email, &u.Name, &u.Department, &u.Role, &u.IsActive, &u.CreatedAt, &u.UpdatedAt); err != nil { return nil, err } users = append(users, u) } return users, rows.Err() } func (s *userStore) get(id, tenantID string) (*User, error) { var u User err := s.db.QueryRow( `SELECT id, tenant_id, email, name, COALESCE(department,''), role, is_active, created_at, updated_at FROM users WHERE id = $1 AND tenant_id = $2`, id, tenantID, ).Scan(&u.ID, &u.TenantID, &u.Email, &u.Name, &u.Department, &u.Role, &u.IsActive, &u.CreatedAt, &u.UpdatedAt) if err == sql.ErrNoRows { return nil, nil } return &u, err } func (s *userStore) create(u User, passwordHash string) (*User, error) { var created User err := s.db.QueryRow( `INSERT INTO users (tenant_id, email, name, department, role, is_active, password_hash) VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id, tenant_id, email, name, COALESCE(department,''), role, is_active, created_at, updated_at`, u.TenantID, u.Email, u.Name, u.Department, u.Role, u.IsActive, passwordHash, ).Scan(&created.ID, &created.TenantID, &created.Email, &created.Name, &created.Department, &created.Role, &created.IsActive, &created.CreatedAt, &created.UpdatedAt) return &created, err } func (s *userStore) update(u User, passwordHash string) (*User, error) { var updated User var err error if passwordHash != "" { err = s.db.QueryRow( `UPDATE users SET email=$1, name=$2, department=$3, role=$4, is_active=$5, password_hash=$6, updated_at=NOW() WHERE id=$7 AND tenant_id=$8 RETURNING id, tenant_id, email, name, COALESCE(department,''), role, is_active, created_at, updated_at`, u.Email, u.Name, u.Department, u.Role, u.IsActive, passwordHash, u.ID, u.TenantID, ).Scan(&updated.ID, &updated.TenantID, &updated.Email, &updated.Name, &updated.Department, &updated.Role, &updated.IsActive, &updated.CreatedAt, &updated.UpdatedAt) } else { err = s.db.QueryRow( `UPDATE users SET email=$1, name=$2, department=$3, role=$4, is_active=$5, updated_at=NOW() WHERE id=$6 AND tenant_id=$7 RETURNING id, tenant_id, email, name, COALESCE(department,''), role, is_active, created_at, updated_at`, u.Email, u.Name, u.Department, u.Role, u.IsActive, u.ID, u.TenantID, ).Scan(&updated.ID, &updated.TenantID, &updated.Email, &updated.Name, &updated.Department, &updated.Role, &updated.IsActive, &updated.CreatedAt, &updated.UpdatedAt) } if err == sql.ErrNoRows { return nil, nil } return &updated, err } func (s *userStore) delete(id, tenantID string) error { res, err := s.db.Exec( `DELETE FROM users WHERE id = $1 AND tenant_id = $2`, id, tenantID) if err != nil { return err } n, _ := res.RowsAffected() if n == 0 { return sql.ErrNoRows } return nil } // ─── HTTP handlers ──────────────────────────────────────────────────────────── func (h *Handler) listUsers(w http.ResponseWriter, r *http.Request) { tenantID, ok := tenantFromCtx(w, r) if !ok { return } if h.db == nil { apierror.WriteError(w, &apierror.APIError{ Type: "not_implemented", Message: "database not configured", HTTPStatus: http.StatusNotImplemented, }) return } us := newUserStore(h.db, h.logger) users, err := us.list(tenantID) if err != nil { apierror.WriteError(w, apierror.NewUpstreamError("failed to list users: "+err.Error())) return } if users == nil { users = []User{} } writeJSON(w, http.StatusOK, map[string]interface{}{"data": users}) } func (h *Handler) createUser(w http.ResponseWriter, r *http.Request) { tenantID, ok := tenantFromCtx(w, r) if !ok { return } if h.db == nil { apierror.WriteError(w, &apierror.APIError{Type: "not_implemented", Message: "database not configured", HTTPStatus: http.StatusNotImplemented}) return } var req createUserRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { apierror.WriteError(w, apierror.NewBadRequestError("invalid JSON: "+err.Error())) return } if req.Email == "" || req.Name == "" { apierror.WriteError(w, apierror.NewBadRequestError("email and name are required")) return } role := req.Role if role == "" { role = "user" } isActive := true if req.IsActive != nil { isActive = *req.IsActive } passwordHash := "" if req.Password != "" { hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { apierror.WriteError(w, apierror.NewUpstreamError("failed to hash password")) return } passwordHash = string(hash) } us := newUserStore(h.db, h.logger) created, err := us.create(User{ TenantID: tenantID, Email: req.Email, Name: req.Name, Department: req.Department, Role: role, IsActive: isActive, }, passwordHash) if err != nil { apierror.WriteError(w, apierror.NewUpstreamError("failed to create user: "+err.Error())) return } writeJSON(w, http.StatusCreated, created) } func (h *Handler) getUser(w http.ResponseWriter, r *http.Request) { tenantID, ok := tenantFromCtx(w, r) if !ok { return } id := chi.URLParam(r, "id") if h.db == nil { apierror.WriteError(w, &apierror.APIError{Type: "not_implemented", Message: "database not configured", HTTPStatus: http.StatusNotImplemented}) return } us := newUserStore(h.db, h.logger) u, err := us.get(id, tenantID) if err != nil { apierror.WriteError(w, apierror.NewUpstreamError(err.Error())) return } if u == nil { apierror.WriteError(w, &apierror.APIError{Type: "not_found_error", Message: "user not found", HTTPStatus: http.StatusNotFound}) return } writeJSON(w, http.StatusOK, u) } func (h *Handler) updateUser(w http.ResponseWriter, r *http.Request) { tenantID, ok := tenantFromCtx(w, r) if !ok { return } id := chi.URLParam(r, "id") if h.db == nil { apierror.WriteError(w, &apierror.APIError{Type: "not_implemented", Message: "database not configured", HTTPStatus: http.StatusNotImplemented}) return } var req createUserRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { apierror.WriteError(w, apierror.NewBadRequestError("invalid JSON: "+err.Error())) return } isActive := true if req.IsActive != nil { isActive = *req.IsActive } passwordHash := "" if req.Password != "" { hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { apierror.WriteError(w, apierror.NewUpstreamError("failed to hash password")) return } passwordHash = string(hash) } us := newUserStore(h.db, h.logger) updated, err := us.update(User{ ID: id, TenantID: tenantID, Email: req.Email, Name: req.Name, Department: req.Department, Role: req.Role, IsActive: isActive, }, passwordHash) if err != nil { apierror.WriteError(w, apierror.NewUpstreamError(err.Error())) return } if updated == nil { apierror.WriteError(w, &apierror.APIError{Type: "not_found_error", Message: "user not found", HTTPStatus: http.StatusNotFound}) return } writeJSON(w, http.StatusOK, updated) } func (h *Handler) deleteUser(w http.ResponseWriter, r *http.Request) { tenantID, ok := tenantFromCtx(w, r) if !ok { return } id := chi.URLParam(r, "id") if h.db == nil { apierror.WriteError(w, &apierror.APIError{Type: "not_implemented", Message: "database not configured", HTTPStatus: http.StatusNotImplemented}) return } us := newUserStore(h.db, h.logger) if err := us.delete(id, tenantID); err != nil { if err == sql.ErrNoRows { apierror.WriteError(w, &apierror.APIError{Type: "not_found_error", Message: "user not found", HTTPStatus: http.StatusNotFound}) return } apierror.WriteError(w, apierror.NewUpstreamError(err.Error())) return } w.WriteHeader(http.StatusNoContent) } func (h *Handler) getProviderStatus(w http.ResponseWriter, r *http.Request) { if h.router == nil { apierror.WriteError(w, &apierror.APIError{Type: "not_implemented", Message: "provider router not configured", HTTPStatus: http.StatusNotImplemented}) return } statuses := h.router.ProviderStatuses() // Also call health check for each provider (E2-10). healthCtx := r.Context() type providerStatusResponse struct { Provider string `json:"provider"` State string `json:"state"` Failures int `json:"failures"` OpenedAt string `json:"opened_at,omitempty"` Healthy *bool `json:"healthy,omitempty"` } _ = healthCtx // suppress unused warning; health ping is async in production writeJSON(w, http.StatusOK, statuses) } // tenantFromMiddlewareCtx is an alias kept for consistency. func tenantFromMiddlewareCtx(r *http.Request) (string, bool) { claims, ok := middleware.ClaimsFromContext(r.Context()) if !ok || claims.TenantID == "" { return "", false } return claims.TenantID, true }