first commit
This commit is contained in:
18
backend/internal/clients/model.go
Normal file
18
backend/internal/clients/model.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package clients
|
||||
|
||||
import "time"
|
||||
|
||||
type Client struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Status string `json:"status"`
|
||||
WebsiteURL string `json:"website_url"`
|
||||
InstagramURL string `json:"instagram_url"`
|
||||
FacebookURL string `json:"facebook_url"`
|
||||
LinkedinURL string `json:"linkedin_url"`
|
||||
Color string `json:"color"`
|
||||
Notes string `json:"notes"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
196
backend/internal/clients/routes.go
Normal file
196
backend/internal/clients/routes.go
Normal file
@@ -0,0 +1,196 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"mira/backend/internal/auth"
|
||||
"mira/backend/internal/users"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Routes struct {
|
||||
store Store
|
||||
}
|
||||
|
||||
func NewRoutes(store Store) Routes {
|
||||
return Routes{store: store}
|
||||
}
|
||||
|
||||
func (r Routes) Register(router *gin.RouterGroup, requireAuth gin.HandlerFunc) {
|
||||
router.Use(requireAuth)
|
||||
|
||||
router.GET("", r.list)
|
||||
router.POST("", r.create)
|
||||
router.GET("/:clientId", r.get)
|
||||
router.PATCH("/:clientId", r.update)
|
||||
router.DELETE("/:clientId", r.delete)
|
||||
}
|
||||
|
||||
func (r Routes) list(c *gin.Context) {
|
||||
claims, ok := auth.ClaimsFromContext(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"message": "Autenticacao obrigatoria."})
|
||||
return
|
||||
}
|
||||
|
||||
var clients []Client
|
||||
var err error
|
||||
if claims.Role == users.RoleSuperAdmin || claims.Role == users.RoleAgencyUser {
|
||||
clients, err = r.store.List(c.Request.Context())
|
||||
} else {
|
||||
clients, err = r.store.ListForUser(c.Request.Context(), claims.UserID)
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel listar clientes."})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"clients": clients})
|
||||
}
|
||||
|
||||
type createClientRequest struct {
|
||||
Name string `json:"name" binding:"required,min=2"`
|
||||
}
|
||||
|
||||
func (r Routes) create(c *gin.Context) {
|
||||
claims, ok := auth.ClaimsFromContext(c)
|
||||
if !ok || claims.Role != users.RoleSuperAdmin {
|
||||
c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."})
|
||||
return
|
||||
}
|
||||
|
||||
var input createClientRequest
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "Informe um nome de cliente valido."})
|
||||
return
|
||||
}
|
||||
|
||||
client, err := r.store.Create(c.Request.Context(), input.Name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel criar o cliente."})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"client": client})
|
||||
}
|
||||
|
||||
func (r Routes) get(c *gin.Context) {
|
||||
if !r.canReadClient(c, c.Param("clientId")) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."})
|
||||
return
|
||||
}
|
||||
|
||||
client, err := r.store.FindByID(c.Request.Context(), c.Param("clientId"))
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"message": "Cliente nao encontrado."})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel carregar o cliente."})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"client": client})
|
||||
}
|
||||
|
||||
type updateClientRequest struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
WebsiteURL string `json:"website_url"`
|
||||
InstagramURL string `json:"instagram_url"`
|
||||
FacebookURL string `json:"facebook_url"`
|
||||
LinkedinURL string `json:"linkedin_url"`
|
||||
Color string `json:"color"`
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
|
||||
func (r Routes) update(c *gin.Context) {
|
||||
claims, ok := auth.ClaimsFromContext(c)
|
||||
if !ok || claims.Role != users.RoleSuperAdmin {
|
||||
c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."})
|
||||
return
|
||||
}
|
||||
|
||||
var input updateClientRequest
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "Dados invalidos."})
|
||||
return
|
||||
}
|
||||
|
||||
color := strings.TrimSpace(input.Color)
|
||||
if color != "" && !hexColorPattern.MatchString(color) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "Cor invalida."})
|
||||
return
|
||||
}
|
||||
|
||||
client, err := r.store.Update(c.Request.Context(), c.Param("clientId"), input.Name, input.Status, input.WebsiteURL, input.InstagramURL, input.FacebookURL, input.LinkedinURL, color, input.Notes)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"message": "Cliente nao encontrado."})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel atualizar o cliente."})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"client": client})
|
||||
}
|
||||
|
||||
var hexColorPattern = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
|
||||
|
||||
func (r Routes) archive(c *gin.Context) {
|
||||
claims, ok := auth.ClaimsFromContext(c)
|
||||
if !ok || claims.Role != users.RoleSuperAdmin {
|
||||
c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."})
|
||||
return
|
||||
}
|
||||
|
||||
client, err := r.store.Archive(c.Request.Context(), c.Param("clientId"))
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"message": "Cliente nao encontrado."})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel arquivar o cliente."})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"client": client})
|
||||
}
|
||||
|
||||
func (r Routes) delete(c *gin.Context) {
|
||||
claims, ok := auth.ClaimsFromContext(c)
|
||||
if !ok || claims.Role != users.RoleSuperAdmin {
|
||||
c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."})
|
||||
return
|
||||
}
|
||||
|
||||
err := r.store.Delete(c.Request.Context(), c.Param("clientId"))
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"message": "Cliente nao encontrado."})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel excluir o cliente."})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Cliente excluido."})
|
||||
}
|
||||
|
||||
func (r Routes) canReadClient(c *gin.Context, clientID string) bool {
|
||||
claims, ok := auth.ClaimsFromContext(c)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if claims.Role == users.RoleSuperAdmin || claims.Role == users.RoleAgencyUser {
|
||||
return true
|
||||
}
|
||||
|
||||
allowed, err := r.store.UserCanAccess(c.Request.Context(), claims.UserID, clientID)
|
||||
return err == nil && allowed
|
||||
}
|
||||
201
backend/internal/clients/store.go
Normal file
201
backend/internal/clients/store.go
Normal file
@@ -0,0 +1,201 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("client not found")
|
||||
|
||||
type Store struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewStore(db *pgxpool.Pool) Store {
|
||||
return Store{db: db}
|
||||
}
|
||||
|
||||
func (s Store) List(ctx context.Context) ([]Client, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id::text, name, slug, status, website_url, instagram_url, facebook_url, linkedin_url, color, notes, created_at, updated_at
|
||||
FROM clients
|
||||
ORDER BY name ASC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
clients := []Client{}
|
||||
for rows.Next() {
|
||||
client, err := scanClient(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clients = append(clients, client)
|
||||
}
|
||||
|
||||
return clients, rows.Err()
|
||||
}
|
||||
|
||||
func (s Store) ListForUser(ctx context.Context, userID string) ([]Client, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT clients.id::text, clients.name, clients.slug, clients.status, clients.website_url, clients.instagram_url, clients.facebook_url, clients.linkedin_url, clients.color, clients.notes, clients.created_at, clients.updated_at
|
||||
FROM clients
|
||||
INNER JOIN client_users ON client_users.client_id = clients.id
|
||||
WHERE client_users.user_id = $1::uuid
|
||||
ORDER BY clients.name ASC
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
clients := []Client{}
|
||||
for rows.Next() {
|
||||
client, err := scanClient(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clients = append(clients, client)
|
||||
}
|
||||
|
||||
return clients, rows.Err()
|
||||
}
|
||||
|
||||
func (s Store) UserCanAccess(ctx context.Context, userID string, clientID string) (bool, error) {
|
||||
var exists bool
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM client_users
|
||||
WHERE user_id = $1::uuid
|
||||
AND client_id = $2::uuid
|
||||
)
|
||||
`, userID, clientID).Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
func (s Store) FindByID(ctx context.Context, id string) (Client, error) {
|
||||
row := s.db.QueryRow(ctx, `
|
||||
SELECT id::text, name, slug, status, website_url, instagram_url, facebook_url, linkedin_url, color, notes, created_at, updated_at
|
||||
FROM clients
|
||||
WHERE id = $1
|
||||
`, id)
|
||||
|
||||
return scanClient(row)
|
||||
}
|
||||
|
||||
func (s Store) Create(ctx context.Context, name string) (Client, error) {
|
||||
slug := slugify(name)
|
||||
|
||||
row := s.db.QueryRow(ctx, `
|
||||
INSERT INTO clients (name, slug, status)
|
||||
VALUES ($1, $2, 'active')
|
||||
RETURNING id::text, name, slug, status, website_url, instagram_url, facebook_url, linkedin_url, color, notes, created_at, updated_at
|
||||
`, strings.TrimSpace(name), slug)
|
||||
|
||||
return scanClient(row)
|
||||
}
|
||||
|
||||
func (s Store) Update(ctx context.Context, id string, name string, status string, websiteURL string, instagramURL string, facebookURL string, linkedinURL string, color string, notes string) (Client, error) {
|
||||
row := s.db.QueryRow(ctx, `
|
||||
UPDATE clients
|
||||
SET
|
||||
name = COALESCE(NULLIF($2, ''), name),
|
||||
slug = CASE WHEN NULLIF($2, '') IS NULL THEN slug ELSE $3 END,
|
||||
status = COALESCE(NULLIF($4, ''), status),
|
||||
website_url = $5,
|
||||
instagram_url = $6,
|
||||
facebook_url = $7,
|
||||
linkedin_url = $8,
|
||||
color = COALESCE(NULLIF($9, ''), color),
|
||||
notes = $10,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id::text, name, slug, status, website_url, instagram_url, facebook_url, linkedin_url, color, notes, created_at, updated_at
|
||||
`, id, strings.TrimSpace(name), slugify(name), strings.TrimSpace(status), strings.TrimSpace(websiteURL), strings.TrimSpace(instagramURL), strings.TrimSpace(facebookURL), strings.TrimSpace(linkedinURL), strings.TrimSpace(color), strings.TrimSpace(notes))
|
||||
|
||||
return scanClient(row)
|
||||
}
|
||||
|
||||
func (s Store) Archive(ctx context.Context, id string) (Client, error) {
|
||||
row := s.db.QueryRow(ctx, `
|
||||
UPDATE clients
|
||||
SET status = 'archived', updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id::text, name, slug, status, website_url, instagram_url, facebook_url, linkedin_url, color, notes, created_at, updated_at
|
||||
`, id)
|
||||
|
||||
return scanClient(row)
|
||||
}
|
||||
|
||||
func (s Store) Delete(ctx context.Context, id string) error {
|
||||
result, err := s.db.Exec(ctx, `
|
||||
DELETE FROM clients
|
||||
WHERE id = $1
|
||||
`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type scanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanClient(row scanner) (Client, error) {
|
||||
var client Client
|
||||
err := row.Scan(
|
||||
&client.ID,
|
||||
&client.Name,
|
||||
&client.Slug,
|
||||
&client.Status,
|
||||
&client.WebsiteURL,
|
||||
&client.InstagramURL,
|
||||
&client.FacebookURL,
|
||||
&client.LinkedinURL,
|
||||
&client.Color,
|
||||
&client.Notes,
|
||||
&client.CreatedAt,
|
||||
&client.UpdatedAt,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Client{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Client{}, err
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func slugify(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
replacements := map[string]string{
|
||||
"á": "a", "à": "a", "â": "a", "ã": "a", "ä": "a",
|
||||
"é": "e", "è": "e", "ê": "e", "ë": "e",
|
||||
"í": "i", "ì": "i", "î": "i", "ï": "i",
|
||||
"ó": "o", "ò": "o", "ô": "o", "õ": "o", "ö": "o",
|
||||
"ú": "u", "ù": "u", "û": "u", "ü": "u",
|
||||
"ç": "c",
|
||||
}
|
||||
for from, to := range replacements {
|
||||
value = strings.ReplaceAll(value, from, to)
|
||||
}
|
||||
|
||||
value = regexp.MustCompile(`[^a-z0-9]+`).ReplaceAllString(value, "-")
|
||||
value = strings.Trim(value, "-")
|
||||
if value == "" {
|
||||
return "cliente"
|
||||
}
|
||||
return value
|
||||
}
|
||||
Reference in New Issue
Block a user