Files
boc/aamos-admin-upgrade/backend/handlers/users.go
T
Bernt 6de2455917 v1.2.0: Add Global Markets footer, translated to 9 languages
- Added GLOBAL_MARKETS_TITLE to all translation files
- Updated footer with 12 markets (4 active + 8 upcoming)
- Translated market section to: zh-cn, zh-tw, ja, ko, th, vi, id, ms, hi
- Built and deployed to production
- CloudFront invalidation: I3RTMXVFDJXWLG3SYX208OP1CC
2026-07-08 19:56:03 +00:00

300 lines
7.5 KiB
Go

package handlers
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
// UsersHandler hanterar alla /api/v1/users/* rutter.
type UsersHandler struct {
DB *sql.DB
}
// EnsureSchema lägger till deleted_at om kolumnen saknas.
func (h *UsersHandler) EnsureSchema() error {
_, err := h.DB.Exec(
`ALTER TABLE users ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ`,
)
return err
}
// adminUser är den fullständiga representationen som returneras av admin-API:et.
type adminUser struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Role string `json:"role"`
CreatedAt time.Time `json:"created_at"`
LastLogin time.Time `json:"last_login"`
}
type createUserReq struct {
Email string `json:"email"`
Name string `json:"name"`
Role string `json:"role"`
Password string `json:"password"`
}
type updateUserReq struct {
Name string `json:"name"`
Role string `json:"role"`
}
type usersListResp struct {
Users []adminUser `json:"users"`
Total int `json:"total"`
Page int `json:"page"`
Limit int `json:"limit"`
}
// ServeHTTP dispatchar till list/create/get/update/softDelete baserat på metod och path.
func (h *UsersHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
tail := strings.TrimPrefix(r.URL.Path, "/api/v1/users")
if tail == "" || tail == "/" {
switch r.Method {
case http.MethodGet:
h.list(w, r)
case http.MethodPost:
h.create(w, r)
default:
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
}
return
}
id := strings.TrimPrefix(tail, "/")
if id == "" || strings.Contains(id, "/") {
writeError(w, http.StatusNotFound, "not found")
return
}
switch r.Method {
case http.MethodGet:
h.get(w, r, id)
case http.MethodPut:
h.update(w, r, id)
case http.MethodDelete:
h.softDelete(w, r, id)
default:
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
}
}
// GET /api/v1/users?page=1&limit=20
func (h *UsersHandler) list(w http.ResponseWriter, r *http.Request) {
page, limit := parsePagination(r)
offset := (page - 1) * limit
var total int
if err := h.DB.QueryRowContext(r.Context(),
`SELECT COUNT(*) FROM users WHERE deleted_at IS NULL`,
).Scan(&total); err != nil {
writeError(w, http.StatusInternalServerError, "db error")
return
}
rows, err := h.DB.QueryContext(r.Context(),
`SELECT id, email, name, role, created_at, last_login
FROM users
WHERE deleted_at IS NULL
ORDER BY created_at DESC
LIMIT $1 OFFSET $2`,
limit, offset,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "db error")
return
}
defer rows.Close()
users := make([]adminUser, 0, limit)
for rows.Next() {
var u adminUser
if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.Role, &u.CreatedAt, &u.LastLogin); err != nil {
writeError(w, http.StatusInternalServerError, "scan error")
return
}
users = append(users, u)
}
if err := rows.Err(); err != nil {
writeError(w, http.StatusInternalServerError, "rows error")
return
}
writeJSON(w, http.StatusOK, usersListResp{
Users: users,
Total: total,
Page: page,
Limit: limit,
})
}
// GET /api/v1/users/{id}
func (h *UsersHandler) get(w http.ResponseWriter, r *http.Request, id string) {
var u adminUser
err := h.DB.QueryRowContext(r.Context(),
`SELECT id, email, name, role, created_at, last_login
FROM users
WHERE id = $1 AND deleted_at IS NULL`,
id,
).Scan(&u.ID, &u.Email, &u.Name, &u.Role, &u.CreatedAt, &u.LastLogin)
if err == sql.ErrNoRows {
writeError(w, http.StatusNotFound, "user not found")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "db error")
return
}
writeJSON(w, http.StatusOK, u)
}
// POST /api/v1/users
func (h *UsersHandler) create(w http.ResponseWriter, r *http.Request) {
var req createUserReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
if req.Email == "" {
writeError(w, http.StatusBadRequest, "email required")
return
}
if req.Password == "" {
writeError(w, http.StatusBadRequest, "password required")
return
}
if req.Role == "" {
req.Role = "user"
}
if req.Role != "admin" && req.Role != "user" {
writeError(w, http.StatusBadRequest, "role must be admin or user")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
writeError(w, http.StatusInternalServerError, "password hash failed")
return
}
id, err := newUUID()
if err != nil {
writeError(w, http.StatusInternalServerError, "id generation failed")
return
}
now := time.Now().UTC()
_, err = h.DB.ExecContext(r.Context(),
`INSERT INTO users (id, email, name, role, password_hash, created_at, last_login)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
id, req.Email, req.Name, req.Role, string(hash), now, now,
)
if err != nil {
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") {
writeError(w, http.StatusConflict, "email already exists")
return
}
writeError(w, http.StatusInternalServerError, "db error")
return
}
writeJSON(w, http.StatusCreated, adminUser{
ID: id,
Email: req.Email,
Name: req.Name,
Role: req.Role,
CreatedAt: now,
LastLogin: now,
})
}
// PUT /api/v1/users/{id}
func (h *UsersHandler) update(w http.ResponseWriter, r *http.Request, id string) {
var req updateUserReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
if req.Role != "" && req.Role != "admin" && req.Role != "user" {
writeError(w, http.StatusBadRequest, "role must be admin or user")
return
}
// NULLIF gör att tom role-sträng bevarar befintligt värde.
res, err := h.DB.ExecContext(r.Context(),
`UPDATE users
SET name = $1,
role = COALESCE(NULLIF($2, ''), role)
WHERE id = $3 AND deleted_at IS NULL`,
req.Name, req.Role, id,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "db error")
return
}
if n, _ := res.RowsAffected(); n == 0 {
writeError(w, http.StatusNotFound, "user not found")
return
}
h.get(w, r, id)
}
// DELETE /api/v1/users/{id} — soft delete, sätter deleted_at
func (h *UsersHandler) softDelete(w http.ResponseWriter, r *http.Request, id string) {
res, err := h.DB.ExecContext(r.Context(),
`UPDATE users SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL`,
id,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "db error")
return
}
if n, _ := res.RowsAffected(); n == 0 {
writeError(w, http.StatusNotFound, "user not found")
return
}
w.WriteHeader(http.StatusNoContent)
}
func parsePagination(r *http.Request) (page, limit int) {
page, limit = 1, 20
if v, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && v > 0 {
page = v
}
if v, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && v > 0 && v <= 100 {
limit = v
}
return
}
// newUUID genererar ett random UUID v4.
func newUUID() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
b[6] = (b[6] & 0x0f) | 0x40 // version 4
b[8] = (b[8] & 0x3f) | 0x80 // variant RFC 4122
return fmt.Sprintf("%s-%s-%s-%s-%s",
hex.EncodeToString(b[0:4]),
hex.EncodeToString(b[4:6]),
hex.EncodeToString(b[6:8]),
hex.EncodeToString(b[8:10]),
hex.EncodeToString(b[10:]),
), nil
}