341 lines
10 KiB
Go
341 lines
10 KiB
Go
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
|
|
}
|