first commit
All checks were successful
CI / backend (push) Successful in 13m19s
CI / frontend (push) Successful in 11m3s
CI / docker (push) Successful in 1m58s

This commit is contained in:
Cauê Faleiros
2026-06-03 16:31:42 -03:00
commit 8c7e5fbbe4
92 changed files with 18226 additions and 0 deletions

View File

@@ -0,0 +1,209 @@
package users
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"time"
"github.com/jackc/pgx/v5"
)
var ErrInvitationNotFound = errors.New("invitation not found")
type Invitation struct {
ID string `json:"id"`
Email string `json:"email"`
Role string `json:"role"`
ClientID *string `json:"client_id"`
ClientName *string `json:"client_name"`
ExpiresAt time.Time `json:"expires_at"`
AcceptedAt *time.Time `json:"accepted_at"`
CreatedBy *string `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
InvitationURL string `json:"invitation_url,omitempty"`
}
type CreateInvitationInput struct {
Email string
Role string
ClientID string
TokenHash string
ExpiresAt time.Time
CreatedBy string
}
func NewInvitationToken() (string, string, error) {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", "", err
}
token := base64.RawURLEncoding.EncodeToString(bytes)
return token, HashInvitationToken(token), nil
}
func HashInvitationToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
func (s Store) CreateInvitation(ctx context.Context, input CreateInvitationInput) (Invitation, error) {
row := s.db.QueryRow(ctx, `
INSERT INTO invitations (email, role, client_id, token_hash, expires_at, created_by)
VALUES (lower($1), $2, NULLIF($3, '')::uuid, $4, $5, NULLIF($6, '')::uuid)
RETURNING
invitations.id::text,
invitations.email,
invitations.role,
invitations.client_id::text,
(SELECT name FROM clients WHERE clients.id = invitations.client_id),
invitations.expires_at,
invitations.accepted_at,
invitations.created_by::text,
invitations.created_at
`, input.Email, input.Role, input.ClientID, input.TokenHash, input.ExpiresAt, input.CreatedBy)
return scanInvitation(row)
}
func (s Store) ListInvitations(ctx context.Context) ([]Invitation, error) {
rows, err := s.db.Query(ctx, `
SELECT
invitations.id::text,
invitations.email,
invitations.role,
invitations.client_id::text,
clients.name,
invitations.expires_at,
invitations.accepted_at,
invitations.created_by::text,
invitations.created_at
FROM invitations
LEFT JOIN clients ON clients.id = invitations.client_id
ORDER BY invitations.created_at DESC
`)
if err != nil {
return nil, err
}
defer rows.Close()
invitations := []Invitation{}
for rows.Next() {
invitation, err := scanInvitation(rows)
if err != nil {
return nil, err
}
invitations = append(invitations, invitation)
}
return invitations, rows.Err()
}
func (s Store) AcceptInvitation(ctx context.Context, tokenHash string, name string, passwordHash string) (User, error) {
tx, err := s.db.Begin(ctx)
if err != nil {
return User{}, err
}
defer tx.Rollback(ctx)
var invitation Invitation
var rawTokenHash string
err = tx.QueryRow(ctx, `
SELECT
invitations.id::text,
invitations.email,
invitations.role,
invitations.client_id::text,
(SELECT name FROM clients WHERE clients.id = invitations.client_id),
invitations.expires_at,
invitations.accepted_at,
invitations.created_by::text,
invitations.created_at,
invitations.token_hash
FROM invitations
WHERE invitations.token_hash = $1
AND invitations.accepted_at IS NULL
AND invitations.expires_at > now()
FOR UPDATE
`, tokenHash).Scan(
&invitation.ID,
&invitation.Email,
&invitation.Role,
&invitation.ClientID,
&invitation.ClientName,
&invitation.ExpiresAt,
&invitation.AcceptedAt,
&invitation.CreatedBy,
&invitation.CreatedAt,
&rawTokenHash,
)
if errors.Is(err, pgx.ErrNoRows) {
return User{}, ErrInvitationNotFound
}
if err != nil {
return User{}, err
}
user, err := scanUser(tx.QueryRow(ctx, `
INSERT INTO users (email, name, password_hash, role, status)
VALUES ($1, $2, $3, $4, 'active')
RETURNING id::text, email, name, password_hash, role, status, created_at, updated_at
`, invitation.Email, name, passwordHash, invitation.Role))
if err != nil {
return User{}, err
}
if invitation.ClientID != nil {
if _, err := tx.Exec(ctx, `
INSERT INTO client_users (client_id, user_id)
VALUES ($1::uuid, $2::uuid)
ON CONFLICT DO NOTHING
`, *invitation.ClientID, user.ID); err != nil {
return User{}, err
}
}
if _, err := tx.Exec(ctx, `
UPDATE invitations
SET accepted_at = now()
WHERE token_hash = $1
`, rawTokenHash); err != nil {
return User{}, err
}
if err := tx.Commit(ctx); err != nil {
return User{}, err
}
return user, nil
}
type invitationScanner interface {
Scan(dest ...any) error
}
func scanInvitation(row invitationScanner) (Invitation, error) {
var invitation Invitation
err := row.Scan(
&invitation.ID,
&invitation.Email,
&invitation.Role,
&invitation.ClientID,
&invitation.ClientName,
&invitation.ExpiresAt,
&invitation.AcceptedAt,
&invitation.CreatedBy,
&invitation.CreatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return Invitation{}, ErrInvitationNotFound
}
if err != nil {
return Invitation{}, err
}
return invitation, nil
}

View File

@@ -0,0 +1,20 @@
package users
import "time"
const (
RoleSuperAdmin = "super_admin"
RoleAgencyUser = "agency_user"
RoleClientViewer = "client_viewer"
)
type User struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
PasswordHash string `json:"-"`
Role string `json:"role"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

View File

@@ -0,0 +1,118 @@
package users
import (
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
)
type Routes struct {
store Store
appPublicURL string
}
func NewRoutes(store Store, appPublicURL string) Routes {
return Routes{
store: store,
appPublicURL: strings.TrimRight(appPublicURL, "/"),
}
}
func (r Routes) Register(router *gin.RouterGroup, requireAuth gin.HandlerFunc) {
router.Use(requireAuth)
router.GET("", r.list)
router.GET("/invitations", r.listInvitations)
router.POST("/invitations", r.createInvitation)
}
func (r Routes) list(c *gin.Context) {
if c.GetString("auth_role") != RoleSuperAdmin {
c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."})
return
}
users, err := r.store.List(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel listar usuarios."})
return
}
c.JSON(http.StatusOK, gin.H{"users": users})
}
func (r Routes) listInvitations(c *gin.Context) {
if c.GetString("auth_role") != RoleSuperAdmin {
c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."})
return
}
invitations, err := r.store.ListInvitations(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel listar convites."})
return
}
c.JSON(http.StatusOK, gin.H{"invitations": invitations})
}
type createInvitationRequest struct {
Email string `json:"email" binding:"required,email"`
Role string `json:"role" binding:"required"`
ClientID string `json:"client_id"`
}
func (r Routes) createInvitation(c *gin.Context) {
if c.GetString("auth_role") != RoleSuperAdmin {
c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."})
return
}
var input createInvitationRequest
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "Informe os dados do convite."})
return
}
role := strings.TrimSpace(input.Role)
if role != RoleAgencyUser && role != RoleClientViewer {
c.JSON(http.StatusBadRequest, gin.H{"message": "Perfil invalido para convite."})
return
}
if role == RoleClientViewer && strings.TrimSpace(input.ClientID) == "" {
c.JSON(http.StatusBadRequest, gin.H{"message": "Convites de cliente precisam de um cliente vinculado."})
return
}
token, tokenHash, err := NewInvitationToken()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel gerar o convite."})
return
}
invitation, err := r.store.CreateInvitation(c.Request.Context(), CreateInvitationInput{
Email: strings.TrimSpace(input.Email),
Role: role,
ClientID: strings.TrimSpace(input.ClientID),
TokenHash: tokenHash,
ExpiresAt: time.Now().Add(7 * 24 * time.Hour),
CreatedBy: c.GetString("auth_user_id"),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel criar o convite."})
return
}
invitation.InvitationURL = r.invitationURL(token)
c.JSON(http.StatusCreated, gin.H{"invitation": invitation})
}
func (r Routes) invitationURL(token string) string {
if r.appPublicURL == "" {
return "/accept-invitation?token=" + token
}
return r.appPublicURL + "/accept-invitation?token=" + token
}

View File

@@ -0,0 +1,100 @@
package users
import (
"context"
"errors"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrNotFound = errors.New("user not found")
type Store struct {
db *pgxpool.Pool
}
func NewStore(db *pgxpool.Pool) Store {
return Store{db: db}
}
func (s Store) FindByEmail(ctx context.Context, email string) (User, error) {
row := s.db.QueryRow(ctx, `
SELECT id::text, email, name, password_hash, role, status, created_at, updated_at
FROM users
WHERE lower(email) = lower($1)
`, strings.TrimSpace(email))
return scanUser(row)
}
func (s Store) FindByID(ctx context.Context, id string) (User, error) {
row := s.db.QueryRow(ctx, `
SELECT id::text, email, name, password_hash, role, status, created_at, updated_at
FROM users
WHERE id = $1
`, id)
return scanUser(row)
}
func (s Store) List(ctx context.Context) ([]User, error) {
rows, err := s.db.Query(ctx, `
SELECT id::text, email, name, password_hash, role, status, created_at, updated_at
FROM users
ORDER BY created_at DESC
`)
if err != nil {
return nil, err
}
defer rows.Close()
users := []User{}
for rows.Next() {
user, err := scanUser(rows)
if err != nil {
return nil, err
}
users = append(users, user)
}
return users, rows.Err()
}
func (s Store) UpsertSuperAdmin(ctx context.Context, email string, passwordHash string) (User, error) {
row := s.db.QueryRow(ctx, `
INSERT INTO users (email, name, password_hash, role, status)
VALUES ($1, 'Super Admin', $2, 'super_admin', 'active')
ON CONFLICT (email)
DO UPDATE SET
password_hash = EXCLUDED.password_hash,
role = 'super_admin',
status = 'active',
updated_at = now()
RETURNING id::text, email, name, password_hash, role, status, created_at, updated_at
`, strings.TrimSpace(email), passwordHash)
return scanUser(row)
}
func scanUser(row pgx.Row) (User, error) {
var user User
err := row.Scan(
&user.ID,
&user.Email,
&user.Name,
&user.PasswordHash,
&user.Role,
&user.Status,
&user.CreatedAt,
&user.UpdatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return User{}, ErrNotFound
}
if err != nil {
return User{}, err
}
return user, nil
}