diff --git a/codex-prompt/prompt-7.txt b/codex-prompt/prompt-7.txt index e69de29..f6a0656 100644 --- a/codex-prompt/prompt-7.txt +++ b/codex-prompt/prompt-7.txt @@ -0,0 +1,22 @@ +Implementa modulo “users” sotto /web/templates/private/users. + +Routes protette (RequireAuth): +- GET /users -> pagina con search + container tabella +- GET /users/table -> partial HTML tabella (htmx) +- GET /users/:id/modal -> partial HTML contenuto modal + +Requisiti tabella: +- query params: q, sort (id|name|email whitelist), dir (asc|desc), page, pageSize +- server-driven paging/sort/search usando GORM (Count + Limit/Offset + Order) +- _table.html deve includere: + - header th cliccabili con hx-get (toggle dir) + - pager prev/next con hx-get + - bottone “Apri” che hx-get sul modal e hx-target="#userModal" hx-swap="innerHTML" + - apri modal via JS minimal: setAttribute('open','') dopo swap (o onclick) + +Crea template: +- private/users/index.html +- private/users/_table.html +- private/users/_modal.html + +Integra nella index privata. \ No newline at end of file diff --git a/internal/controllers/users_controller.go b/internal/controllers/users_controller.go new file mode 100644 index 0000000..8aba0bf --- /dev/null +++ b/internal/controllers/users_controller.go @@ -0,0 +1,125 @@ +package controllers + +import ( + "fmt" + "html/template" + "path/filepath" + "strconv" + "strings" + + httpmw "trustcontact/internal/http/middleware" + "trustcontact/internal/services" + + "github.com/gofiber/fiber/v2" +) + +type UsersController struct { + usersService *services.UsersService +} + +func NewUsersController(usersService *services.UsersService) *UsersController { + return &UsersController{usersService: usersService} +} + +func (uc *UsersController) Index(c *fiber.Ctx) error { + pageData, err := uc.queryPage(c) + if err != nil { + return err + } + + viewData := map[string]any{ + "Title": "Users", + "NavSection": "private", + "PageData": pageData, + } + for k, v := range localsTemplateData(c) { + viewData[k] = v + } + + tmpl, err := template.ParseFiles( + "web/templates/layout.html", + "web/templates/public/_flash.html", + "web/templates/private/users/index.html", + "web/templates/private/users/_table.html", + ) + if err != nil { + return err + } + + return executeLayout(c, tmpl, viewData) +} + +func (uc *UsersController) Table(c *fiber.Ctx) error { + pageData, err := uc.queryPage(c) + if err != nil { + return err + } + + viewData := map[string]any{"PageData": pageData} + tmpl, err := template.ParseFiles("web/templates/private/users/_table.html") + if err != nil { + return err + } + + c.Type("html", "utf-8") + return tmpl.ExecuteTemplate(c.Response().BodyWriter(), "users_table", viewData) +} + +func (uc *UsersController) Modal(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil || id == 0 { + return c.Status(fiber.StatusBadRequest).SendString("invalid user id") + } + + user, err := uc.usersService.GetByID(uint(id)) + if err != nil { + return err + } + if user == nil { + return c.Status(fiber.StatusNotFound).SendString("user not found") + } + + viewData := map[string]any{"User": user} + tmpl, err := template.ParseFiles("web/templates/private/users/_modal.html") + if err != nil { + return err + } + + c.Type("html", "utf-8") + return tmpl.ExecuteTemplate(c.Response().BodyWriter(), "users_modal", viewData) +} + +func (uc *UsersController) queryPage(c *fiber.Ctx) (*services.UsersPage, error) { + page := parseIntOrDefault(c.Query("page"), 1) + pageSize := parseIntOrDefault(c.Query("pageSize"), 10) + q := strings.TrimSpace(c.Query("q")) + sort := c.Query("sort", "id") + dir := c.Query("dir", "asc") + + pageData, err := uc.usersService.List(services.UsersQuery{ + Q: q, + Sort: sort, + Dir: dir, + Page: page, + PageSize: pageSize, + }) + if err != nil { + return nil, fmt.Errorf("list users: %w", err) + } + + return pageData, nil +} + +func parseIntOrDefault(value string, fallback int) int { + parsed, err := strconv.Atoi(value) + if err != nil || parsed <= 0 { + return fallback + } + return parsed +} + +func executeLayout(c *fiber.Ctx, tmpl *template.Template, viewData map[string]any) error { + httpmw.SetTemplateData(c, "NavSection", viewData["NavSection"]) + c.Type("html", "utf-8") + return tmpl.ExecuteTemplate(c.Response().BodyWriter(), filepath.Base("web/templates/layout.html"), viewData) +} diff --git a/internal/db/seed.go b/internal/db/seed.go index 8a68a9f..e40e3f0 100644 --- a/internal/db/seed.go +++ b/internal/db/seed.go @@ -18,30 +18,35 @@ func Seed(database *gorm.DB) error { seedUsers := []models.User{ { + Name: "Admin User", Email: "admin@example.com", Role: models.RoleAdmin, EmailVerified: true, PasswordHash: passwordHash, }, { + Name: "Normal User", Email: "user@example.com", Role: models.RoleUser, EmailVerified: true, PasswordHash: passwordHash, }, { + Name: "Demo One", Email: "demo1@example.com", Role: models.RoleUser, EmailVerified: true, PasswordHash: passwordHash, }, { + Name: "Demo Two", Email: "demo2@example.com", Role: models.RoleUser, EmailVerified: true, PasswordHash: passwordHash, }, { + Name: "Demo Three", Email: "demo3@example.com", Role: models.RoleUser, EmailVerified: true, @@ -62,6 +67,7 @@ func upsertUser(database *gorm.DB, user models.User) error { result := database.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "email"}}, DoUpdates: clause.AssignmentColumns([]string{ + "name", "role", "email_verified", "password_hash", diff --git a/internal/http/router.go b/internal/http/router.go index 4f5052d..577765f 100644 --- a/internal/http/router.go +++ b/internal/http/router.go @@ -22,6 +22,7 @@ func RegisterRoutes(app *fiber.App, store *session.Store, database *gorm.DB, cfg return fmt.Errorf("init auth service: %w", err) } authController := controllers.NewAuthController(authService) + usersController := controllers.NewUsersController(services.NewUsersService(database)) app.Get("/healthz", func(c *fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) @@ -39,10 +40,13 @@ func RegisterRoutes(app *fiber.App, store *session.Store, database *gorm.DB, cfg app.Post("/forgot-password", authController.ForgotPassword) app.Get("/reset-password", authController.ShowResetPassword) app.Post("/reset-password", authController.ResetPassword) + app.Get("/users", httpmw.RequireAuth(), usersController.Index) + app.Get("/users/table", httpmw.RequireAuth(), usersController.Table) + app.Get("/users/:id/modal", httpmw.RequireAuth(), usersController.Modal) private := app.Group("/private", httpmw.RequireAuth()) private.Get("/", func(c *fiber.Ctx) error { - return c.SendString("private area") + return c.Redirect("/users") }) admin := app.Group("/admin", httpmw.RequireAuth(), httpmw.RequireAdmin()) diff --git a/internal/models/user.go b/internal/models/user.go index 5d1ecaf..562e512 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -9,6 +9,7 @@ const ( type User struct { ID uint `gorm:"primaryKey"` + Name string `gorm:"size:120;index"` Email string `gorm:"size:320;uniqueIndex;not null"` PasswordHash string `gorm:"size:255;not null"` EmailVerified bool `gorm:"not null;default:false"` diff --git a/internal/repo/user_repo.go b/internal/repo/user_repo.go index 310ce92..6b46c5b 100644 --- a/internal/repo/user_repo.go +++ b/internal/repo/user_repo.go @@ -2,6 +2,8 @@ package repo import ( "errors" + "fmt" + "strings" "trustcontact/internal/models" @@ -12,6 +14,14 @@ type UserRepo struct { db *gorm.DB } +type UserListParams struct { + Query string + Sort string + Dir string + Page int + PageSize int +} + func NewUserRepo(db *gorm.DB) *UserRepo { return &UserRepo{db: db} } @@ -55,3 +65,65 @@ func (r *UserRepo) UpdatePasswordHash(userID uint, passwordHash string) error { Where("id = ?", userID). Update("password_hash", passwordHash).Error } + +func (r *UserRepo) List(params UserListParams) ([]models.User, int64, error) { + query := r.db.Model(&models.User{}) + + search := strings.TrimSpace(params.Query) + if search != "" { + like := "%" + strings.ToLower(search) + "%" + query = query.Where("LOWER(name) LIKE ? OR LOWER(email) LIKE ?", like, like) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + orderBy := sanitizeSort(params.Sort) + orderDir := sanitizeDir(params.Dir) + orderClause := fmt.Sprintf("%s %s", orderBy, orderDir) + + page := params.Page + if page < 1 { + page = 1 + } + pageSize := params.PageSize + if pageSize <= 0 { + pageSize = 10 + } + if pageSize > 100 { + pageSize = 100 + } + + offset := (page - 1) * pageSize + + var users []models.User + if err := query.Order(orderClause).Limit(pageSize).Offset(offset).Find(&users).Error; err != nil { + return nil, 0, err + } + + return users, total, nil +} + +func sanitizeSort(sort string) string { + switch strings.ToLower(strings.TrimSpace(sort)) { + case "id": + return "id" + case "name": + return "name" + case "email": + return "email" + default: + return "id" + } +} + +func sanitizeDir(dir string) string { + switch strings.ToLower(strings.TrimSpace(dir)) { + case "desc": + return "desc" + default: + return "asc" + } +} diff --git a/internal/services/users_service.go b/internal/services/users_service.go new file mode 100644 index 0000000..3ad9416 --- /dev/null +++ b/internal/services/users_service.go @@ -0,0 +1,132 @@ +package services + +import ( + "strings" + + "trustcontact/internal/models" + "trustcontact/internal/repo" + + "gorm.io/gorm" +) + +type UsersService struct { + users *repo.UserRepo +} + +type UsersQuery struct { + Q string + Sort string + Dir string + Page int + PageSize int +} + +type UsersPage struct { + Users []models.User + Total int64 + Page int + PageSize int + TotalPages int + HasPrev bool + HasNext bool + PrevPage int + NextPage int + Sort string + Dir string + Q string +} + +func NewUsersService(database *gorm.DB) *UsersService { + return &UsersService{users: repo.NewUserRepo(database)} +} + +func (s *UsersService) List(query UsersQuery) (*UsersPage, error) { + page := query.Page + if page < 1 { + page = 1 + } + pageSize := query.PageSize + if pageSize <= 0 { + pageSize = 10 + } + if pageSize > 100 { + pageSize = 100 + } + + sort := normalizeSort(query.Sort) + dir := normalizeDir(query.Dir) + + users, total, err := s.users.List(repo.UserListParams{ + Query: strings.TrimSpace(query.Q), + Sort: sort, + Dir: dir, + Page: page, + PageSize: pageSize, + }) + if err != nil { + return nil, err + } + + totalPages := 0 + if total > 0 { + totalPages = int((total + int64(pageSize) - 1) / int64(pageSize)) + } + if totalPages > 0 && page > totalPages { + page = totalPages + users, total, err = s.users.List(repo.UserListParams{ + Query: strings.TrimSpace(query.Q), + Sort: sort, + Dir: dir, + Page: page, + PageSize: pageSize, + }) + if err != nil { + return nil, err + } + } + + hasPrev := page > 1 + hasNext := totalPages > 0 && page < totalPages + + return &UsersPage{ + Users: users, + Total: total, + Page: page, + PageSize: pageSize, + TotalPages: totalPages, + HasPrev: hasPrev, + HasNext: hasNext, + PrevPage: max(1, page-1), + NextPage: page + 1, + Sort: sort, + Dir: dir, + Q: strings.TrimSpace(query.Q), + }, nil +} + +func (s *UsersService) GetByID(id uint) (*models.User, error) { + return s.users.FindByID(id) +} + +func normalizeSort(sort string) string { + switch strings.ToLower(strings.TrimSpace(sort)) { + case "id", "name", "email": + return strings.ToLower(strings.TrimSpace(sort)) + default: + return "id" + } +} + +func normalizeDir(dir string) string { + if strings.ToLower(strings.TrimSpace(dir)) == "desc" { + return "desc" + } + return "asc" +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/web/templates/layout.html b/web/templates/layout.html index 54e393f..0f45b7d 100644 --- a/web/templates/layout.html +++ b/web/templates/layout.html @@ -17,6 +17,7 @@ .muted { color: #6b7280; font-size: 0.95rem; } .row { display: flex; gap: 10px; flex-wrap: wrap; } +