photum/backend/internal/auth/handler.go
NANDO9322 f8bb2e66dd feat: suporte completo multi-região (SP/MG) e melhorias na validação de importação
Detalhes das alterações:

[Banco de Dados]
- Ajuste nas constraints UNIQUE das tabelas de catálogo (cursos, empresas, tipos_eventos, etc.) para incluir a coluna `regiao`, permitindo dados duplicados entre regiões mas únicos por região.
- Correção crítica na constraint da tabela `precos_tipos_eventos` para evitar conflitos de UPSERT (ON CONFLICT) durante a inicialização.
- Implementação de lógica de Seed para a região 'MG':
  - Clonagem automática de catálogos base de 'SP' para 'MG' (Tipos de Evento, Serviços, etc.).
  - Inserção de tabela de preços específica para 'MG' via script de migração.

[Backend - Go]
- Atualização geral dos Handlers e Services para filtrar dados baseados no cabeçalho `x-regiao`.
- Ajuste no Middleware de autenticação para processar e repassar o contexto da região.
- Correção de queries SQL (geradas pelo sqlc) para suportar os novos filtros regionais.

[Frontend - React]
- Implementação do envio global do cabeçalho `x-regiao` nas requisições da API.
- Correção no componente [PriceTableEditor](cci:1://file:///c:/Projetos/photum/frontend/components/System/PriceTableEditor.tsx:26:0-217:2) para carregar e salvar preços respeitando a região selecionada (fix de "Preços zerados" em MG).
- Refatoração profunda na tela de Importação ([ImportData.tsx](cci:7://file:///c:/Projetos/photum/frontend/pages/ImportData.tsx:0:0-0:0)):
  - Adição de feedback visual detalhado para registros ignorados.
  - Categorização explícita de erros: "CPF Inválido", "Região Incompatível", "Linha Vazia/Separador".
  - Correção na lógica de contagem para considerar linhas vazias explicitamente no relatório final, garantindo que o total bata com o Excel.

[Geral]
- Correção de diversos erros de lint e tipagem TSX.
- Padronização de logs de erro no backend para facilitar debug.
2026-02-05 16:18:40 -03:00

703 lines
20 KiB
Go

package auth
import (
"net/http"
"strings"
"photum-backend/internal/profissionais"
"photum-backend/internal/storage"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type Handler struct {
service *Service
s3Service *storage.S3Service
}
func NewHandler(service *Service, s3Service *storage.S3Service) *Handler {
return &Handler{service: service, s3Service: s3Service}
}
type uploadURLRequest struct {
Filename string `json:"filename" binding:"required"`
ContentType string `json:"content_type" binding:"required"`
}
// GetUploadURL godoc
// @Summary Get S3 Presigned URL for upload
// @Description Get a pre-signed URL to upload a file directly to S3/Civo
// @Tags auth
// @Accept json
// @Produce json
// @Param request body uploadURLRequest true "Upload URL Request"
// @Success 200 {object} map[string]string
// @Router /auth/upload-url [post]
func (h *Handler) GetUploadURL(c *gin.Context) {
var req uploadURLRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
uploadURL, publicURL, err := h.s3Service.GeneratePresignedURL(req.Filename, req.ContentType)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"upload_url": uploadURL,
"public_url": publicURL,
})
}
type registerRequest struct {
Email string `json:"email" binding:"required,email"`
Senha string `json:"senha" binding:"required,min=6"`
Nome string `json:"nome" binding:"required"`
Telefone string `json:"telefone"`
Role string `json:"role" binding:"required"`
EmpresaID string `json:"empresa_id"`
TipoProfissional string `json:"tipo_profissional"` // New field
}
// Register godoc
// @Summary Register a new user
// @Description Register a new user with email, password, name, phone, role and professional type
// @Tags auth
// @Accept json
// @Produce json
// @Param request body registerRequest true "Register Request"
// @Success 201 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /auth/register [post]
func (h *Handler) Register(c *gin.Context) {
var req registerRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Create professional data only if role is appropriate
var profData *profissionais.CreateProfissionalInput
if req.Role == "BUSINESS_OWNER" {
profData = &profissionais.CreateProfissionalInput{
Nome: req.Nome,
Whatsapp: &req.Telefone,
}
}
var empresaIDPtr *string
if req.EmpresaID != "" {
empresaIDPtr = &req.EmpresaID
}
regiao := c.GetString("regiao")
if regiao == "" {
regiao = c.GetHeader("x-regiao")
}
// Default to SP if still empty
if regiao == "" {
regiao = "SP"
}
user, err := h.service.Register(c.Request.Context(), req.Email, req.Senha, req.Role, req.Nome, req.Telefone, req.TipoProfissional, empresaIDPtr, profData, regiao)
if err != nil {
if strings.Contains(err.Error(), "duplicate key") {
c.JSON(http.StatusConflict, gin.H{"error": "email already registered"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Auto-login after registration
tokenPair, _, _, err := h.service.Login(c.Request.Context(), req.Email, req.Senha)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "user created but failed to auto-login"})
return
}
http.SetCookie(c.Writer, &http.Cookie{
Name: "refresh_token",
Value: tokenPair.RefreshToken,
Path: "/auth/refresh",
HttpOnly: true,
Secure: false,
SameSite: http.SameSiteStrictMode,
MaxAge: 30 * 24 * 60 * 60,
})
// Set access_token cookie for fallback
http.SetCookie(c.Writer, &http.Cookie{
Name: "access_token",
Value: tokenPair.AccessToken,
Path: "/",
HttpOnly: true,
Secure: false,
SameSite: http.SameSiteStrictMode,
MaxAge: 15 * 60,
})
c.JSON(http.StatusCreated, gin.H{
"message": "user created",
"access_token": tokenPair.AccessToken,
"user": gin.H{
"id": uuid.UUID(user.ID.Bytes).String(),
"email": user.Email,
"role": user.Role,
"ativo": user.Ativo,
},
})
}
type loginRequest struct {
Email string `json:"email" binding:"required,email" example:"admin@photum.com"`
Senha string `json:"senha" binding:"required,min=6" example:"123456"`
}
type loginResponse struct {
AccessToken string `json:"access_token"`
ExpiresAt string `json:"expires_at"`
User userResponse `json:"user"`
Profissional interface{} `json:"profissional,omitempty"`
}
type userResponse struct {
ID string `json:"id"`
Email string `json:"email"`
Role string `json:"role"`
Ativo bool `json:"ativo"`
Name string `json:"name,omitempty"`
Phone string `json:"phone,omitempty"`
CompanyID string `json:"company_id,omitempty"`
CompanyName string `json:"company_name,omitempty"`
AllowedRegions []string `json:"allowed_regions"`
}
// Login godoc
// @Summary Login
// @Description Login with email and password
// @Tags auth
// @Accept json
// @Produce json
// @Param request body loginRequest true "Login Request"
// @Success 200 {object} loginResponse
// @Failure 401 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /auth/login [post]
func (h *Handler) Login(c *gin.Context) {
var req loginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tokenPair, user, profData, err := h.service.Login(c.Request.Context(), req.Email, req.Senha)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
http.SetCookie(c.Writer, &http.Cookie{
Name: "refresh_token",
Value: tokenPair.RefreshToken,
Path: "/auth/refresh",
HttpOnly: true,
Secure: false,
SameSite: http.SameSiteStrictMode,
MaxAge: 30 * 24 * 60 * 60,
})
// Set access_token cookie for fallback
http.SetCookie(c.Writer, &http.Cookie{
Name: "access_token",
Value: tokenPair.AccessToken,
Path: "/",
HttpOnly: true,
Secure: false,
SameSite: http.SameSiteStrictMode,
MaxAge: 15 * 60, // 15 mins
})
// Handle Nullable Fields
var companyID, companyName string
if user.EmpresaID.Valid {
companyID = uuid.UUID(user.EmpresaID.Bytes).String()
}
if user.EmpresaNome.Valid {
companyName = user.EmpresaNome.String
}
resp := loginResponse{
AccessToken: tokenPair.AccessToken,
ExpiresAt: "2025-...", // logic to calculate if needed, or remove field
User: userResponse{
ID: uuid.UUID(user.ID.Bytes).String(),
Email: user.Email,
Role: user.Role,
Ativo: user.Ativo,
Name: user.Nome,
Phone: user.Whatsapp,
CompanyID: companyID,
CompanyName: companyName,
AllowedRegions: user.RegioesPermitidas,
},
}
if profData != nil {
resp.Profissional = map[string]interface{}{
"id": uuid.UUID(profData.ID.Bytes).String(),
"nome": profData.Nome,
"funcao_profissional_id": uuid.UUID(profData.FuncaoProfissionalID.Bytes).String(),
"funcao_profissional": "", // Deprecated/Removed from query
"functions": profData.Functions,
"equipamentos": profData.Equipamentos.String,
"avatar_url": profData.AvatarUrl.String,
}
}
c.JSON(http.StatusOK, resp)
}
// Refresh godoc
// @Summary Refresh access token
// @Description Get a new access token using a valid refresh token
// @Tags auth
// @Accept json
// @Produce json
// @Param refresh_token body string false "Refresh Token"
// @Success 200 {object} map[string]interface{}
// @Failure 401 {object} map[string]string
// @Router /auth/refresh [post]
func (h *Handler) Refresh(c *gin.Context) {
refreshToken, err := c.Cookie("refresh_token")
if err != nil {
var req struct {
RefreshToken string `json:"refresh_token"`
}
if err := c.ShouldBindJSON(&req); err == nil {
refreshToken = req.RefreshToken
}
}
if refreshToken == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "refresh token required"})
return
}
accessToken, accessExp, err := h.service.Refresh(c.Request.Context(), refreshToken)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"})
return
}
c.JSON(http.StatusOK, gin.H{
"access_token": accessToken,
"expires_at": accessExp,
})
}
// Logout godoc
// @Summary Logout user
// @Description Revoke refresh token and clear cookie
// @Tags auth
// @Accept json
// @Produce json
// @Param refresh_token body string false "Refresh Token"
// @Success 200 {object} map[string]string
// @Router /auth/logout [post]
func (h *Handler) Logout(c *gin.Context) {
refreshToken, err := c.Cookie("refresh_token")
if err != nil {
var req struct {
RefreshToken string `json:"refresh_token"`
}
if err := c.ShouldBindJSON(&req); err == nil {
refreshToken = req.RefreshToken
}
}
if refreshToken != "" {
_ = h.service.Logout(c.Request.Context(), refreshToken)
}
c.SetCookie("refresh_token", "", -1, "/", "", false, true)
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
}
// Me godoc
// @Summary Get current user
// @Description Get current authenticated user
// @Tags auth
// @Accept json
// @Produce json
// @Success 200 {object} loginResponse
// @Router /api/me [get]
func (h *Handler) Me(c *gin.Context) {
// User ID comes from context (AuthMiddleware)
userID, exists := c.Get("userID")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
// We can fetch fresh user data
user, err := h.service.GetUser(c.Request.Context(), userID.(string))
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
return
}
var empresaNome string
if user.EmpresaNome.Valid {
empresaNome = user.EmpresaNome.String
}
var empresaID string
if user.EmpresaID.Valid {
empresaID = uuid.UUID(user.EmpresaID.Bytes).String()
}
// Ensure valid slice for JSON response (avoid null)
allowedRegions := make([]string, 0)
if user.RegioesPermitidas != nil {
allowedRegions = append(allowedRegions, user.RegioesPermitidas...)
}
resp := loginResponse{
User: userResponse{
ID: uuid.UUID(user.ID.Bytes).String(),
Email: user.Email,
Role: user.Role,
Ativo: user.Ativo,
Name: user.Nome,
Phone: user.Whatsapp,
CompanyName: empresaNome,
CompanyID: empresaID,
AllowedRegions: allowedRegions,
},
}
if user.Role == "PHOTOGRAPHER" || user.Role == "BUSINESS_OWNER" {
regiao := c.GetString("regiao")
// If regiao is empty, we might skip fetching professional data or default?
// For now if empty, GetProfessionalByUserID with valid=true and string="" will likely fail or return empty?
// Queries check regiao = $2. If regiao is "", and DB has "SP", it won't match.
// So user needs to send header for Me to see pro data.
if regiao != "" {
profData, err := h.service.GetProfessionalByUserID(c.Request.Context(), uuid.UUID(user.ID.Bytes).String())
if err == nil && profData != nil {
resp.Profissional = map[string]interface{}{
"id": uuid.UUID(profData.ID.Bytes).String(),
"nome": profData.Nome,
"funcao_profissional_id": uuid.UUID(profData.FuncaoProfissionalID.Bytes).String(),
"funcao_profissional": "", // Deprecated
"functions": profData.Functions,
"equipamentos": profData.Equipamentos.String,
"avatar_url": profData.AvatarUrl.String,
}
}
}
}
c.JSON(http.StatusOK, resp)
}
// ListPending godoc
// @Summary List pending users
// @Description List users with ativo=false
// @Tags admin
// @Accept json
// @Produce json
// @Success 200 {array} map[string]interface{}
// @Failure 500 {object} map[string]string
// @Security BearerAuth
// @Router /api/admin/users/pending [get]
func (h *Handler) ListPending(c *gin.Context) {
regiao := c.GetString("regiao")
if regiao == "" {
regiao = c.GetHeader("x-regiao")
}
users, err := h.service.ListPendingUsers(c.Request.Context(), regiao)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Map to response
// The generated type ListUsuariosPendingRow fields are:
// ID pgtype.UUID
// Email string
// Role string
// Ativo bool
// CriadoEm pgtype.Timestamptz
// Nome pgtype.Text
// Whatsapp pgtype.Text
resp := make([]map[string]interface{}, len(users))
for i, u := range users {
nome := u.Nome
whatsapp := u.Whatsapp
var empresaNome string
if u.EmpresaNome.Valid {
empresaNome = u.EmpresaNome.String
}
resp[i] = map[string]interface{}{
"id": uuid.UUID(u.ID.Bytes).String(),
"email": u.Email,
"role": u.Role,
"ativo": u.Ativo,
"created_at": u.CriadoEm.Time,
"name": nome, // Mapped to name for frontend compatibility
"phone": whatsapp,
"company_name": empresaNome,
"professional_type": u.TipoProfissional.String, // Add this
}
}
c.JSON(http.StatusOK, resp)
}
// Approve godoc
// @Summary Approve user
// @Description Set user ativo=true
// @Tags admin
// @Accept json
// @Produce json
// @Param id path string true "User ID"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Security BearerAuth
// @Router /api/admin/users/{id}/approve [patch]
func (h *Handler) Approve(c *gin.Context) {
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "id required"})
return
}
err := h.service.ApproveUser(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "user approved"})
}
// AdminCreateUser godoc
// @Summary Create user (Admin)
// @Description Create a new user with specific role (Admin only)
// @Tags admin
// @Accept json
// @Produce json
// @Param request body registerRequest true "Create User Request"
// @Success 201 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Security BearerAuth
// @Router /api/admin/users [post]
func (h *Handler) AdminCreateUser(c *gin.Context) {
var req registerRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Just reuse the request struct but call AdminCreateUser service
regiao := c.GetString("regiao")
// If Admin doesn't specify region in header?
// Maybe Admin API should accept region in body?
// For now use header context.
if regiao == "" {
// Fallback or Error? Admin creation usually implies target region.
// Let's assume header is present or default.
regiao = "SP" // Default for now if missing? Or error?
}
user, err := h.service.AdminCreateUser(c.Request.Context(), req.Email, req.Senha, req.Role, req.Nome, req.TipoProfissional, true, regiao)
if err != nil {
if strings.Contains(err.Error(), "duplicate key") {
c.JSON(http.StatusConflict, gin.H{"error": "email already registered"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{
"message": "user created",
"id": uuid.UUID(user.ID.Bytes).String(),
"email": user.Email,
})
}
type updateRoleRequest struct {
Role string `json:"role" binding:"required"`
}
// UpdateRole godoc
// @Summary Update user role
// @Description Update user role (Admin only)
// @Tags admin
// @Accept json
// @Produce json
// @Param id path string true "User ID"
// @Param request body updateRoleRequest true "Update Role Request"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Security BearerAuth
// @Router /api/admin/users/{id}/role [patch]
func (h *Handler) UpdateRole(c *gin.Context) {
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "id required"})
return
}
var req updateRoleRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
err := h.service.UpdateUserRole(c.Request.Context(), id, req.Role)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "role updated"})
}
// DeleteUser godoc
// @Summary Delete user
// @Description Delete user (Admin only)
// @Tags admin
// @Accept json
// @Produce json
// @Param id path string true "User ID"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Security BearerAuth
// @Router /api/admin/users/{id} [delete]
func (h *Handler) DeleteUser(c *gin.Context) {
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "id required"})
return
}
err := h.service.DeleteUser(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "user deleted"})
}
// ListUsers godoc
// @Summary List all users
// @Description List all users (Admin only)
// @Tags admin
// @Accept json
// @Produce json
// @Success 200 {array} map[string]interface{}
// @Failure 500 {object} map[string]string
// @Security BearerAuth
// @Router /api/admin/users [get]
func (h *Handler) ListUsers(c *gin.Context) {
users, err := h.service.ListUsers(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
resp := make([]map[string]interface{}, len(users))
for i, u := range users {
empresaId := ""
if u.EmpresaID.Valid {
empresaId = uuid.UUID(u.EmpresaID.Bytes).String()
}
resp[i] = map[string]interface{}{
"id": uuid.UUID(u.ID.Bytes).String(),
"email": u.Email,
"role": u.Role,
"ativo": u.Ativo,
"created_at": u.CriadoEm.Time,
"name": u.Nome,
"phone": u.Whatsapp,
"company_name": u.EmpresaNome.String,
"company_id": empresaId,
"professional_type": u.TipoProfissional.String,
}
}
c.JSON(http.StatusOK, resp)
}
// GetUser godoc
// @Summary Get user by ID
// @Description Get user details by ID (Admin only)
// @Tags admin
// @Accept json
// @Produce json
// @Param id path string true "User ID"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Security BearerAuth
// @Router /api/admin/users/{id} [get]
func (h *Handler) GetUser(c *gin.Context) {
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "id required"})
return
}
user, err := h.service.GetUser(c.Request.Context(), id)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
var empresaNome string
if user.EmpresaNome.Valid {
empresaNome = user.EmpresaNome.String
}
var empresaID string
if user.EmpresaID.Valid {
empresaID = uuid.UUID(user.EmpresaID.Bytes).String()
}
resp := loginResponse{
User: userResponse{
ID: uuid.UUID(user.ID.Bytes).String(),
Email: user.Email,
Role: user.Role,
Ativo: user.Ativo,
Name: user.Nome,
Phone: user.Whatsapp,
CompanyName: empresaNome,
CompanyID: empresaID,
AllowedRegions: user.RegioesPermitidas,
},
}
c.JSON(http.StatusOK, resp)
}