diff --git a/cmd/main.go b/cmd/main.go index 69fc8d6..0e84fbc 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -8,6 +8,7 @@ import ( "github.com/henriquepw/imperium-tattoo/database" "github.com/henriquepw/imperium-tattoo/web/handler" + "github.com/henriquepw/imperium-tattoo/web/service" _ "github.com/joho/godotenv/autoload" _ "github.com/tursodatabase/libsql-client-go/libsql" @@ -31,7 +32,8 @@ func main() { clientHandler := handler.NewClientHandler() server.HandleFunc("GET /clients", clientHandler.ClientsPage) - employeeHandler := handler.NewEmployeeHandler(database.NewEmployeeRepo(db)) + employeeSvc := service.NewEmployeeService(database.NewEmployeeRepo(db)) + employeeHandler := handler.NewEmployeeHandler(employeeSvc) server.HandleFunc("GET /employees", employeeHandler.EmployeesPage) server.HandleFunc("GET /employees/create", employeeHandler.EmployeeCreatePage) server.HandleFunc("POST /employees/create", employeeHandler.EmployeeCreateAction) diff --git a/database/employee_repository.go b/database/employee_repository.go index ceefb36..1439b27 100644 --- a/database/employee_repository.go +++ b/database/employee_repository.go @@ -8,20 +8,21 @@ import ( "github.com/henriquepw/imperium-tattoo/web/types" ) -type EmployeeRepository interface { +type EmployeeRepo interface { Insert(ctx context.Context, payload types.EmployeeCreateDTO) (*string, error) List(ctx context.Context) ([]types.Employee, error) + CheckEmail(ctx context.Context, email string) bool } -type EmployeeRepo struct { +type repo struct { db *sql.DB } -func NewEmployeeRepo(db *sql.DB) *EmployeeRepo { - return &EmployeeRepo{db} +func NewEmployeeRepo(db *sql.DB) *repo { + return &repo{db} } -func (r EmployeeRepo) Insert(ctx context.Context, payload types.EmployeeCreateDTO) (*string, error) { +func (r repo) Insert(ctx context.Context, payload types.EmployeeCreateDTO) (*string, error) { id, err := web.NewID() if err != nil { return nil, err @@ -49,7 +50,7 @@ func (r EmployeeRepo) Insert(ctx context.Context, payload types.EmployeeCreateDT ctx, "INSERT INTO credential (id, secret) VALUES ($1, $2)", payload.Email, - payload.PasswordHash, + payload.Password, ) if err != nil { return nil, err @@ -62,7 +63,7 @@ func (r EmployeeRepo) Insert(ctx context.Context, payload types.EmployeeCreateDT return &id, nil } -func (r EmployeeRepo) List(ctx context.Context) ([]types.Employee, error) { +func (r repo) List(ctx context.Context) ([]types.Employee, error) { rows, err := r.db.QueryContext(ctx, "SELECT id, name, email, roles FROM employee") if err != nil { return nil, err @@ -81,3 +82,8 @@ func (r EmployeeRepo) List(ctx context.Context) ([]types.Employee, error) { return items, nil } + +func (r repo) CheckEmail(ctx context.Context, email string) bool { + row := r.db.QueryRowContext(ctx, "SELECT COUNT(1) FROM employee WHERE email = ?", email) + return row.Err() == nil +} diff --git a/go.mod b/go.mod index a4f4aff..7bca2b7 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/henriquepw/imperium-tattoo -go 1.22.4 +go 1.23.1 require ( github.com/a-h/templ v0.2.747 @@ -8,6 +8,7 @@ require ( github.com/joho/godotenv v1.5.1 github.com/matoous/go-nanoid/v2 v2.1.0 github.com/tursodatabase/libsql-client-go v0.0.0-20240812094001-348a4e45b535 + golang.org/x/crypto v0.22.0 ) require ( @@ -17,7 +18,6 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/leodido/go-urn v1.4.0 // indirect - golang.org/x/crypto v0.22.0 // indirect golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect golang.org/x/net v0.24.0 // indirect golang.org/x/sys v0.21.0 // indirect diff --git a/static/css/input.css b/static/css/input.css index 0c72d59..ac950b1 100644 --- a/static/css/input.css +++ b/static/css/input.css @@ -2,6 +2,22 @@ @tailwind components; @tailwind utilities; +@layer components { + .htmx-request .hx-indicator { + display: block; + } + + .htmx-request .default-indicator { + display: none; + } + + .btn-primary { + @apply flex items-center justify-center gap-1.5 h-9 px-3 rounded bg-accent-9; + @apply font-bold text-gray-12 text-center; + @apply transition-all hover:bg-accent-10 active:bg-accent-11 active:scale-95; + } +} + @layer base { @font-face { font-family: raleway; diff --git a/web/handler/employee.go b/web/handler/employee.go new file mode 100644 index 0000000..304d7ce --- /dev/null +++ b/web/handler/employee.go @@ -0,0 +1,50 @@ +package handler + +import ( + "net/http" + + "github.com/a-h/templ" + "github.com/henriquepw/imperium-tattoo/web" + "github.com/henriquepw/imperium-tattoo/web/service" + "github.com/henriquepw/imperium-tattoo/web/types" + "github.com/henriquepw/imperium-tattoo/web/view/employee" +) + +type EmployeeHandler struct { + svc service.EmployeeService +} + +func NewEmployeeHandler(svc service.EmployeeService) *EmployeeHandler { + return &EmployeeHandler{svc} +} + +func (h EmployeeHandler) EmployeesPage(w http.ResponseWriter, r *http.Request) { + web.RenderPage(w, r, employee.EmployeesPage) +} + +func (h EmployeeHandler) EmployeeCreatePage(w http.ResponseWriter, r *http.Request) { + web.RenderPage(w, r, employee.EmployeeCreatePage) +} + +func (h EmployeeHandler) EmployeeCreateAction(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + payload := types.EmployeeCreateDTO{ + Name: r.Form.Get("name"), + Email: r.Form.Get("email"), + Roles: r.Form.Get("roles"), + Password: r.Form.Get("email"), + } + + _, err := h.svc.CreateEmployee(r.Context(), payload) + if err != nil { + web.RenderError(w, r, err, func(e web.ServerError) templ.Component { + return employee.EmployeeCreateForm(employee.EmployeeCreateFormProps{ + Values: payload, + Errors: e.Errors, + }) + }) + return + } + + http.Redirect(w, r, "/employees", http.StatusSeeOther) +} diff --git a/web/handler/employee_handler.go b/web/handler/employee_handler.go deleted file mode 100644 index a0dbce0..0000000 --- a/web/handler/employee_handler.go +++ /dev/null @@ -1,62 +0,0 @@ -package handler - -import ( - "net/http" - - "github.com/henriquepw/imperium-tattoo/database" - "github.com/henriquepw/imperium-tattoo/web" - "github.com/henriquepw/imperium-tattoo/web/types" - "github.com/henriquepw/imperium-tattoo/web/view/employee" -) - -type EmployeeHandler struct { - repo database.EmployeeRepository -} - -func NewEmployeeHandler(repo database.EmployeeRepository) EmployeeHandler { - return EmployeeHandler{repo} -} - -func (h EmployeeHandler) EmployeesPage(w http.ResponseWriter, r *http.Request) { - boosted := r.Header.Get("HX-Boosted") == "true" - web.Render(w, r, http.StatusOK, employee.EmployeesPage(boosted)) -} - -func (h EmployeeHandler) EmployeeCreatePage(w http.ResponseWriter, r *http.Request) { - boosted := r.Header.Get("HX-Boosted") == "true" - web.Render(w, r, http.StatusOK, employee.EmployeeCreatePage(boosted)) -} - -func (h EmployeeHandler) EmployeeCreateAction(w http.ResponseWriter, r *http.Request) { - r.ParseForm() - payload := types.EmployeeCreateDTO{ - Name: r.Form.Get("name"), - Email: r.Form.Get("email"), - Roles: r.Form.Get("roles"), - PasswordHash: r.Form.Get("email"), - } - - if err := web.CheckPayload(payload); err != nil { - web.Render(w, r, http.StatusOK, employee.EmployeeCreateForm(employee.EmployeeCreateFormProps{ - Values: payload, - Errors: err.(web.ServerError).Errors, - })) - return - } - - _, err := h.repo.Insert(r.Context(), payload) - if err != nil { - errors := err.(web.ServerError).Errors - if errors == nil { - errors = map[string]string{"password": "Email e/ou senha inválidos"} - } - - web.Render(w, r, http.StatusOK, employee.EmployeeCreateForm(employee.EmployeeCreateFormProps{ - Values: payload, - Errors: errors, - })) - return - } - - web.Render(w, r, http.StatusOK, employee.EmployeeCreateForm(employee.EmployeeCreateFormProps{})) -} diff --git a/web/hash.go b/web/hash.go new file mode 100644 index 0000000..ffc5ff4 --- /dev/null +++ b/web/hash.go @@ -0,0 +1,15 @@ +package web + +import ( + "golang.org/x/crypto/bcrypt" +) + +func HashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14) + return string(bytes), err +} + +func VerifyPassword(password, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} diff --git a/web/parsers.go b/web/parsers.go index b8ea71f..6a36af0 100644 --- a/web/parsers.go +++ b/web/parsers.go @@ -1,20 +1,10 @@ package web import ( - "net/http" "net/url" "strconv" - - "github.com/a-h/templ" ) -func Render(w http.ResponseWriter, r *http.Request, statusCode int, t templ.Component) error { - w.WriteHeader(statusCode) - w.Header().Set("Content-Type", "text/html") - - return t.Render(r.Context(), w) -} - func GetQueryInt(q url.Values, name string, defaultVal int64) int64 { val, err := strconv.ParseInt(q.Get(name), 10, 64) if err != nil { diff --git a/web/renders.go b/web/renders.go new file mode 100644 index 0000000..ae186fc --- /dev/null +++ b/web/renders.go @@ -0,0 +1,37 @@ +package web + +import ( + "net/http" + + "github.com/a-h/templ" +) + +func RenderPage(w http.ResponseWriter, r *http.Request, comp func(boosted bool) templ.Component) error { + t := comp(r.Header.Get("HX-Boosted") == "true") + return Render(w, r, http.StatusOK, t) +} + +func Render(w http.ResponseWriter, r *http.Request, statusCode int, t templ.Component) error { + w.WriteHeader(statusCode) + w.Header().Set("Content-Type", "text/html") + + return t.Render(r.Context(), w) +} + +func RenderError(w http.ResponseWriter, r *http.Request, err error, t func(e ServerError) templ.Component) error { + if e, ok := err.(ServerError); ok { + if e.Errors != nil { + return Render(w, r, e.StatusCode, t(e)) + } + + w.WriteHeader(e.StatusCode) + w.Header().Set("Content-Type", "text/plain") + w.Write([]byte(e.Message)) + return nil + } + + w.WriteHeader(http.StatusInternalServerError) + w.Header().Set("Content-Type", "text/plain") + w.Write([]byte("Houve um erro inesperado")) + return nil +} diff --git a/web/service/employee.go b/web/service/employee.go new file mode 100644 index 0000000..e8fdc9b --- /dev/null +++ b/web/service/employee.go @@ -0,0 +1,48 @@ +package service + +import ( + "context" + + "github.com/henriquepw/imperium-tattoo/database" + "github.com/henriquepw/imperium-tattoo/web" + "github.com/henriquepw/imperium-tattoo/web/types" +) + +type EmployeeService interface { + CreateEmployee(ctx context.Context, payload types.EmployeeCreateDTO) (*string, error) +} + +type EmployeeSvc struct { + repo database.EmployeeRepo +} + +func NewEmployeeService(repo database.EmployeeRepo) *EmployeeSvc { + return &EmployeeSvc{repo} +} + +func (s *EmployeeSvc) CreateEmployee(ctx context.Context, payload types.EmployeeCreateDTO) (*string, error) { + if err := web.CheckPayload(payload); err != nil { + return nil, err + } + + if s.repo.CheckEmail(ctx, payload.Email) { + return nil, web.InvalidRequestDataError(map[string]string{"email": "Email já cadastrado"}) + } + + hash, err := web.HashPassword(payload.Password) + if err != nil { + return nil, err + } + + id, err := s.repo.Insert(ctx, types.EmployeeCreateDTO{ + Name: payload.Name, + Email: payload.Email, + Roles: payload.Roles, + Password: hash, + }) + if err != nil { + return nil, err + } + + return id, nil +} diff --git a/web/service/employee_service.go b/web/service/employee_service.go deleted file mode 100644 index 6d43c33..0000000 --- a/web/service/employee_service.go +++ /dev/null @@ -1 +0,0 @@ -package service diff --git a/web/types/employee.go b/web/types/employee.go index 7f44b2b..60f90fc 100644 --- a/web/types/employee.go +++ b/web/types/employee.go @@ -14,8 +14,8 @@ type Employee struct { } type EmployeeCreateDTO struct { - Name string `json:"name" validate:"required"` - Email string `json:"email" validate:"required,email"` - Roles string `json:"roles" validate:"required"` - PasswordHash string + Name string `json:"name" validate:"required"` + Email string `json:"email" validate:"required,email"` + Roles string `json:"roles" validate:"required"` + Password string `json:"password" validate:"required"` } diff --git a/web/view/client/clients.templ b/web/view/client/clients.templ index 4d41ae1..195cd32 100644 --- a/web/view/client/clients.templ +++ b/web/view/client/clients.templ @@ -1,11 +1,20 @@ package client -import "github.com/henriquepw/imperium-tattoo/web/view/layout" +import ( + "github.com/henriquepw/imperium-tattoo/web/view/layout" + "github.com/henriquepw/imperium-tattoo/web/view/ui" +) templ ClientsPage() { @layout.Dashbaord("Painel", "clients", false) { -
- MAIN -
+ @layout.PageHeader( + "Clientes", + []ui.BreadcrumbItem{ + {Label: "Clientes", Href: "/clients"}, + }, + ) +
+ CLIENTS +
} } diff --git a/web/view/employee/employee_create.templ b/web/view/employee/employee_create.templ index 011d7cd..88da985 100644 --- a/web/view/employee/employee_create.templ +++ b/web/view/employee/employee_create.templ @@ -1,59 +1,58 @@ package employee import ( - "github.com/henriquepw/imperium-tattoo/web/types" - "github.com/henriquepw/imperium-tattoo/web/view/layout" - "github.com/henriquepw/imperium-tattoo/web/view/ui" +"github.com/henriquepw/imperium-tattoo/web/types" +"github.com/henriquepw/imperium-tattoo/web/view/layout" +"github.com/henriquepw/imperium-tattoo/web/view/ui" ) templ EmployeeCreatePage(boosted bool) { - @layout.Dashbaord("Novo Profissional", "employees", boosted) { -
- @layout.PageHeader( - "Novo Profissional", - []ui.BreadcrumbItem{ - {Label: "Profissinais", Href: "/employees"}, - {Label: "Novo", Href: "/employees/create"}, - }, - ) - @EmployeeCreateForm(EmployeeCreateFormProps{}) -
- } +@layout.Dashbaord("Novo Profissional", "employees", boosted) { +
+ @layout.PageHeader( + "Novo Profissional", + []ui.BreadcrumbItem{ + {Label: "Profissinais", Href: "/employees"}, + {Label: "Novo", Href: "/employees/create"}, + }, + ) + @ui.Card() { + @EmployeeCreateForm(EmployeeCreateFormProps{}) + } +
+} } type EmployeeCreateFormProps struct { - Values types.EmployeeCreateDTO - Errors map[string]string +Values types.EmployeeCreateDTO +Errors map[string]string } templ EmployeeCreateForm(props EmployeeCreateFormProps) { - @ui.Card() { -
- @ui.TextInput(ui.TextInputProps{ - Name: "name", - Label: "Nome", - Placeholder: "Nome do Profissinal", - Value: props.Values.Name, - Error: props.Errors["name"], - }) - @ui.TextInput(ui.TextInputProps{ - Name: "email", - Label: "Email", - Placeholder: "exemplo@gmail.com", - Value: props.Values.Email, - Error: props.Errors["email"], - }) - @ui.TextInput(ui.TextInputProps{ - Name: "roles", - Label: "Cargo", - Placeholder: "Admin", - Value: props.Values.Roles, - Error: props.Errors["roles"], - }) - @ui.Button(ui.ButtonProps{Type: "submit", Class: "ml-auto"}) { - - Criar - } -
- } +
+ @ui.TextInput(ui.TextInputProps{ + Name: "name", + Label: "Nome", + Placeholder: "Nome do Profissinal", + Value: props.Values.Name, + Error: props.Errors["name"], + }) + @ui.TextInput(ui.TextInputProps{ + Name: "email", + Label: "Email", + Placeholder: "exemplo@gmail.com", + Value: props.Values.Email, + Error: props.Errors["email"], + }) + @ui.TextInput(ui.TextInputProps{ + Name: "roles", + Label: "Cargo", + Placeholder: "Admin", + Value: props.Values.Roles, + Error: props.Errors["roles"], + }) + @ui.SubmitBtn("save", "Criando...", "ml-auto") { + Criar + } +
} diff --git a/web/view/employee/employees.templ b/web/view/employee/employees.templ index 9b79619..d9e9059 100644 --- a/web/view/employee/employees.templ +++ b/web/view/employee/employees.templ @@ -11,9 +11,9 @@ templ EmployeesPage(boosted bool) { @layout.PageHeader("Profissinais", []ui.BreadcrumbItem{ {Label: "Profissinais", Href: "/employees"}, }) { - @ui.Link(ui.LinkProps{Href: "employees/create"}) { - - } + + @ui.Icon("plus") + }
EMPLOYEES diff --git a/web/view/home/homepage.templ b/web/view/home/homepage.templ index 725d312..20a5620 100644 --- a/web/view/home/homepage.templ +++ b/web/view/home/homepage.templ @@ -1,11 +1,20 @@ package home -import "github.com/henriquepw/imperium-tattoo/web/view/layout" +import ( + "github.com/henriquepw/imperium-tattoo/web/view/layout" + "github.com/henriquepw/imperium-tattoo/web/view/ui" +) templ HomePage() { @layout.Dashbaord("Painel", "home", false) { -
+ @layout.PageHeader( + "Painel", + []ui.BreadcrumbItem{ + {Label: "Painel", Href: "/"}, + }, + ) +
MAIN -
+
} } diff --git a/web/view/layout/base.templ b/web/view/layout/base.templ index 39d5335..14be822 100644 --- a/web/view/layout/base.templ +++ b/web/view/layout/base.templ @@ -17,6 +17,15 @@ templ Base(title string) { { children... } - + } diff --git a/web/view/layout/dashboard.templ b/web/view/layout/dashboard.templ index 864520e..aade234 100644 --- a/web/view/layout/dashboard.templ +++ b/web/view/layout/dashboard.templ @@ -26,7 +26,7 @@ templ Dashbaord(title, route string, boosted bool) { >
-} - -type LinkProps struct { - Class string - Href string -} - -templ Link(props LinkProps) { - - { children... } - -} diff --git a/web/view/ui/card.templ b/web/view/ui/card.templ index 2581575..b21b64a 100644 --- a/web/view/ui/card.templ +++ b/web/view/ui/card.templ @@ -1,7 +1,7 @@ package ui templ Card(className ...string) { -
+
{ children... }
} diff --git a/web/view/ui/form_field.templ b/web/view/ui/form_field.templ index ed91b93..eeb0aea 100644 --- a/web/view/ui/form_field.templ +++ b/web/view/ui/form_field.templ @@ -1,16 +1,13 @@ package ui -type FormFieldProps struct { - Label string - Error string -} - -templ FormField(props FormFieldProps) { -
- +templ FormField(label, formErr string, classname ...string) { +
+ if label != "" { + + } { children... } - if props.Error != "" { - { props.Error } + if formErr != "" { + { formErr } }
} diff --git a/web/view/ui/icon.templ b/web/view/ui/icon.templ new file mode 100644 index 0000000..60362c6 --- /dev/null +++ b/web/view/ui/icon.templ @@ -0,0 +1,5 @@ +package ui + +templ Icon(name string, classname ...string) { + +} diff --git a/web/view/ui/submit_btn.templ b/web/view/ui/submit_btn.templ new file mode 100644 index 0000000..27dc920 --- /dev/null +++ b/web/view/ui/submit_btn.templ @@ -0,0 +1,20 @@ +package ui + +templ SubmitBtn(icon, loadingText string, classname ...string) { + +} diff --git a/web/view/ui/text_input.templ b/web/view/ui/text_input.templ index 797fbb6..791751e 100644 --- a/web/view/ui/text_input.templ +++ b/web/view/ui/text_input.templ @@ -10,13 +10,13 @@ type TextInputProps struct { Placeholder string } -templ TextInput(props TextInputProps) { - @FormField(FormFieldProps{Label: props.Label, Error: props.Error}) { +templ TextInput(p TextInputProps) { + @FormField(p.Label, p.Error, p.Class) {