6de2455917
- 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
300 lines
7.5 KiB
Go
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
|
|
}
|