photum/backend/internal/profissionais/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

559 lines
19 KiB
Go

package profissionais
import (
"encoding/json"
"net/http"
"photum-backend/internal/db/generated"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
type Handler struct {
service *Service
}
func NewHandler(service *Service) *Handler {
return &Handler{service: service}
}
// ProfissionalResponse struct for Swagger and JSON response
type ProfissionalResponse struct {
ID string `json:"id"`
UsuarioID string `json:"usuario_id"`
Nome string `json:"nome"`
FuncaoProfissional string `json:"funcao_profissional"` // Deprecated single name (optional)
FuncaoProfissionalID string `json:"funcao_profissional_id"`
Functions json.RawMessage `json:"functions"` // JSON array
Endereco *string `json:"endereco"`
Cidade *string `json:"cidade"`
Uf *string `json:"uf"`
Whatsapp *string `json:"whatsapp"`
CpfCnpjTitular *string `json:"cpf_cnpj_titular"`
Banco *string `json:"banco"`
Agencia *string `json:"agencia"`
ContaPix *string `json:"conta_pix"`
CarroDisponivel *bool `json:"carro_disponivel"`
TemEstudio *bool `json:"tem_estudio"`
QtdEstudio *int `json:"qtd_estudio"`
TipoCartao *string `json:"tipo_cartao"`
Observacao *string `json:"observacao"`
QualTec *int `json:"qual_tec"`
EducacaoSimpatia *int `json:"educacao_simpatia"`
DesempenhoEvento *int `json:"desempenho_evento"`
DispHorario *int `json:"disp_horario"`
Media *float64 `json:"media"`
TabelaFree *string `json:"tabela_free"`
ExtraPorEquipamento *bool `json:"extra_por_equipamento"`
Equipamentos *string `json:"equipamentos"`
Email *string `json:"email"`
AvatarURL *string `json:"avatar_url"`
}
func toResponse(p interface{}) ProfissionalResponse {
// Handle different types returned by queries (Create returns CadastroProfissionai, List/Get returns Row with join)
// This is a bit hacky, ideally we'd have a unified model or separate response mappers.
// For now, let's check type.
switch v := p.(type) {
case generated.CadastroProfissionai:
return ProfissionalResponse{
ID: uuid.UUID(v.ID.Bytes).String(),
UsuarioID: uuid.UUID(v.UsuarioID.Bytes).String(),
Nome: v.Nome,
FuncaoProfissionalID: uuid.UUID(v.FuncaoProfissionalID.Bytes).String(),
// FuncaoProfissional name is not available in simple insert return without extra query or join
FuncaoProfissional: "",
Functions: json.RawMessage("[]"), // Empty on Create (or Fetch specifically if needed)
Endereco: fromPgText(v.Endereco),
Cidade: fromPgText(v.Cidade),
Uf: fromPgText(v.Uf),
Whatsapp: fromPgText(v.Whatsapp),
CpfCnpjTitular: fromPgText(v.CpfCnpjTitular),
Banco: fromPgText(v.Banco),
Agencia: fromPgText(v.Agencia),
ContaPix: fromPgText(v.ContaPix),
CarroDisponivel: fromPgBool(v.CarroDisponivel),
TemEstudio: fromPgBool(v.TemEstudio),
QtdEstudio: fromPgInt4(v.QtdEstudio),
TipoCartao: fromPgText(v.TipoCartao),
Observacao: fromPgText(v.Observacao),
QualTec: fromPgInt4(v.QualTec),
EducacaoSimpatia: fromPgInt4(v.EducacaoSimpatia),
DesempenhoEvento: fromPgInt4(v.DesempenhoEvento),
DispHorario: fromPgInt4(v.DispHorario),
Media: fromPgNumeric(v.Media),
TabelaFree: fromPgText(v.TabelaFree),
ExtraPorEquipamento: fromPgBool(v.ExtraPorEquipamento),
Equipamentos: fromPgText(v.Equipamentos),
Email: fromPgText(v.Email),
AvatarURL: fromPgText(v.AvatarUrl),
}
case generated.ListProfissionaisRow:
email := fromPgText(v.Email)
if email == nil {
email = fromPgText(v.UsuarioEmail)
}
return ProfissionalResponse{
ID: uuid.UUID(v.ID.Bytes).String(),
UsuarioID: uuid.UUID(v.UsuarioID.Bytes).String(),
Nome: v.Nome,
FuncaoProfissionalID: uuid.UUID(v.FuncaoProfissionalID.Bytes).String(),
FuncaoProfissional: "", // v.FuncaoNome removed from query or changed?
Functions: toJSONRaw(v.Functions),
Endereco: fromPgText(v.Endereco),
Cidade: fromPgText(v.Cidade),
Uf: fromPgText(v.Uf),
Whatsapp: fromPgText(v.Whatsapp),
CpfCnpjTitular: fromPgText(v.CpfCnpjTitular),
Banco: fromPgText(v.Banco),
Agencia: fromPgText(v.Agencia),
ContaPix: fromPgText(v.ContaPix),
CarroDisponivel: fromPgBool(v.CarroDisponivel),
TemEstudio: fromPgBool(v.TemEstudio),
QtdEstudio: fromPgInt4(v.QtdEstudio),
TipoCartao: fromPgText(v.TipoCartao),
Observacao: fromPgText(v.Observacao),
QualTec: fromPgInt4(v.QualTec),
EducacaoSimpatia: fromPgInt4(v.EducacaoSimpatia),
DesempenhoEvento: fromPgInt4(v.DesempenhoEvento),
DispHorario: fromPgInt4(v.DispHorario),
Media: fromPgNumeric(v.Media),
TabelaFree: fromPgText(v.TabelaFree),
ExtraPorEquipamento: fromPgBool(v.ExtraPorEquipamento),
Equipamentos: fromPgText(v.Equipamentos),
Email: email,
AvatarURL: fromPgText(v.AvatarUrl),
}
case generated.GetProfissionalByIDRow:
return ProfissionalResponse{
ID: uuid.UUID(v.ID.Bytes).String(),
UsuarioID: uuid.UUID(v.UsuarioID.Bytes).String(),
Nome: v.Nome,
FuncaoProfissionalID: uuid.UUID(v.FuncaoProfissionalID.Bytes).String(),
FuncaoProfissional: "", // v.FuncaoNome removed
Functions: toJSONRaw(v.Functions),
Endereco: fromPgText(v.Endereco),
Cidade: fromPgText(v.Cidade),
Uf: fromPgText(v.Uf),
Whatsapp: fromPgText(v.Whatsapp),
CpfCnpjTitular: fromPgText(v.CpfCnpjTitular),
Banco: fromPgText(v.Banco),
Agencia: fromPgText(v.Agencia),
ContaPix: fromPgText(v.ContaPix),
CarroDisponivel: fromPgBool(v.CarroDisponivel),
TemEstudio: fromPgBool(v.TemEstudio),
QtdEstudio: fromPgInt4(v.QtdEstudio),
TipoCartao: fromPgText(v.TipoCartao),
Observacao: fromPgText(v.Observacao),
QualTec: fromPgInt4(v.QualTec),
EducacaoSimpatia: fromPgInt4(v.EducacaoSimpatia),
DesempenhoEvento: fromPgInt4(v.DesempenhoEvento),
DispHorario: fromPgInt4(v.DispHorario),
Media: fromPgNumeric(v.Media),
TabelaFree: fromPgText(v.TabelaFree),
ExtraPorEquipamento: fromPgBool(v.ExtraPorEquipamento),
Equipamentos: fromPgText(v.Equipamentos),
Email: fromPgText(v.Email),
AvatarURL: fromPgText(v.AvatarUrl),
}
case generated.GetProfissionalByUsuarioIDRow:
return ProfissionalResponse{
ID: uuid.UUID(v.ID.Bytes).String(),
UsuarioID: uuid.UUID(v.UsuarioID.Bytes).String(),
Nome: v.Nome,
FuncaoProfissionalID: uuid.UUID(v.FuncaoProfissionalID.Bytes).String(),
FuncaoProfissional: "",
Functions: toJSONRaw(v.Functions),
Endereco: fromPgText(v.Endereco),
Cidade: fromPgText(v.Cidade),
Uf: fromPgText(v.Uf),
Whatsapp: fromPgText(v.Whatsapp),
CpfCnpjTitular: fromPgText(v.CpfCnpjTitular),
Banco: fromPgText(v.Banco),
Agencia: fromPgText(v.Agencia),
ContaPix: fromPgText(v.ContaPix),
CarroDisponivel: fromPgBool(v.CarroDisponivel),
TemEstudio: fromPgBool(v.TemEstudio),
QtdEstudio: fromPgInt4(v.QtdEstudio),
TipoCartao: fromPgText(v.TipoCartao),
Observacao: fromPgText(v.Observacao),
QualTec: fromPgInt4(v.QualTec),
EducacaoSimpatia: fromPgInt4(v.EducacaoSimpatia),
DesempenhoEvento: fromPgInt4(v.DesempenhoEvento),
DispHorario: fromPgInt4(v.DispHorario),
Media: fromPgNumeric(v.Media),
TabelaFree: fromPgText(v.TabelaFree),
ExtraPorEquipamento: fromPgBool(v.ExtraPorEquipamento),
Equipamentos: fromPgText(v.Equipamentos),
Email: fromPgText(v.Email),
AvatarURL: fromPgText(v.AvatarUrl),
}
default:
return ProfissionalResponse{}
}
}
// Helpers for conversion
func fromPgText(t pgtype.Text) *string {
if !t.Valid {
return nil
}
return &t.String
}
func fromPgBool(b pgtype.Bool) *bool {
if !b.Valid {
return nil
}
return &b.Bool
}
func fromPgInt4(i pgtype.Int4) *int {
if !i.Valid {
return nil
}
val := int(i.Int32)
return &val
}
func fromPgNumeric(n pgtype.Numeric) *float64 {
if !n.Valid {
return nil
}
f, _ := n.Float64Value()
val := f.Float64
return &val
}
func toJSONRaw(v interface{}) json.RawMessage {
if v == nil {
return json.RawMessage("[]")
}
switch val := v.(type) {
case []byte:
if len(val) == 0 {
return json.RawMessage("[]")
}
return json.RawMessage(val)
case string:
if val == "" {
return json.RawMessage("[]")
}
return json.RawMessage([]byte(val))
default:
// Fallback: marshal strictly? or return empty array
b, _ := json.Marshal(v)
return json.RawMessage(b)
}
}
// Create godoc
// @Summary Create a new profissional
// @Description Create a new profissional record
// @Tags profissionais
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body CreateProfissionalInput true "Create Profissional Request"
// @Success 201 {object} ProfissionalResponse
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/profissionais [post]
func (h *Handler) Create(c *gin.Context) {
var input CreateProfissionalInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID, exists := c.Get("userID")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
return
}
userIDStr, ok := userID.(string)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid user id type in context"})
return
}
// Security: Only allow TargetUserID if user is ADMIN or OWNER
if input.TargetUserID != nil && *input.TargetUserID != "" {
userRole, exists := c.Get("role")
if !exists {
// Should validation fail? Or just ignore target?
// Safer to ignore target user ID if role not found
input.TargetUserID = nil
} else {
roleStr, ok := userRole.(string)
if !ok || (roleStr != "SUPERADMIN" && roleStr != "BUSINESS_OWNER") {
input.TargetUserID = nil
}
}
}
regiao := c.GetString("regiao")
prof, err := h.service.Create(c.Request.Context(), userIDStr, input, regiao)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, toResponse(*prof))
}
// List godoc
// @Summary List profissionais
// @Description List all profissionais
// @Tags profissionais
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {array} ProfissionalResponse
// @Failure 500 {object} map[string]string
// @Router /api/profissionais [get]
func (h *Handler) List(c *gin.Context) {
regiao := c.GetString("regiao")
profs, err := h.service.List(c.Request.Context(), regiao)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
var response []ProfissionalResponse
for _, p := range profs {
response = append(response, toResponse(p))
}
c.JSON(http.StatusOK, response)
}
// Me godoc
// @Summary Get current authenticated profissional
// @Description Get the profissional profile associated with the current user
// @Tags profissionais
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} ProfissionalResponse
// @Failure 401 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/profissionais/me [get]
func (h *Handler) Me(c *gin.Context) {
userID, exists := c.Get("userID")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
return
}
userIDStr, ok := userID.(string)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid user id type"})
return
}
regiao := c.GetString("regiao")
prof, err := h.service.GetByUserID(c.Request.Context(), userIDStr, regiao)
if err != nil {
// Checks if error is "no rows in result set" -> 404
if err.Error() == "no rows in result set" {
c.JSON(http.StatusNotFound, gin.H{"error": "professional profile not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Reuse toResponse which handles different types via interface{} check
// We need to add case for GetProfissionalByUsuarioIDRow in toResponse function
c.JSON(http.StatusOK, toResponse(*prof))
}
// Get godoc
// @Summary Get profissional by ID
// @Description Get a profissional by ID
// @Tags profissionais
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "Profissional ID"
// @Success 200 {object} ProfissionalResponse
// @Failure 400 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/profissionais/{id} [get]
func (h *Handler) Get(c *gin.Context) {
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "id required"})
return
}
regiao := c.GetString("regiao")
prof, err := h.service.GetByID(c.Request.Context(), id, regiao)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, toResponse(*prof))
}
// Update godoc
// @Summary Update profissional
// @Description Update a profissional by ID
// @Tags profissionais
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "Profissional ID"
// @Param request body UpdateProfissionalInput true "Update Profissional Request"
// @Success 200 {object} ProfissionalResponse
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/profissionais/{id} [put]
func (h *Handler) Update(c *gin.Context) {
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "id required"})
return
}
var input UpdateProfissionalInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
regiao := c.GetString("regiao")
prof, err := h.service.Update(c.Request.Context(), id, input, regiao)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, toResponse(*prof))
}
// Delete godoc
// @Summary Delete profissional
// @Description Delete a profissional by ID
// @Tags profissionais
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "Profissional ID"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/profissionais/{id} [delete]
func (h *Handler) Delete(c *gin.Context) {
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "id required"})
return
}
regiao := c.GetString("regiao")
err := h.service.Delete(c.Request.Context(), id, regiao)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
}
// Import godoc
// @Summary Import professionals from properties
// @Description Import professionals (Upsert by CPF)
// @Tags profissionais
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body []CreateProfissionalInput true "List of Professionals"
// @Success 200 {object} ImportStats
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/profissionais/import [post]
func (h *Handler) Import(c *gin.Context) {
var input []CreateProfissionalInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Role check: Only ADMIN or OWNER
userRole, exists := c.Get("role")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
roleStr := userRole.(string)
if roleStr != "SUPERADMIN" && roleStr != "BUSINESS_OWNER" {
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
return
}
regiao := c.GetString("regiao")
stats, errs := h.service.Import(c.Request.Context(), input, regiao)
// Construct response with errors if any
response := gin.H{
"created": stats.Created,
"updated": stats.Updated,
"errors_count": stats.Errors,
}
if len(errs) > 0 {
var errMsgs []string
for _, e := range errs {
errMsgs = append(errMsgs, e.Error())
}
response["errors"] = errMsgs
}
c.JSON(http.StatusOK, response)
}
// CheckCPF godoc
// @Summary Check if professional exists by CPF
// @Description Check existence and claim status
// @Tags profissionais
// @Accept json
// @Produce json
// @Param cpf query string true "CPF/CNPJ"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Router /api/public/profissionais/check [get]
func (h *Handler) CheckCPF(c *gin.Context) {
cpf := c.Query("cpf")
if cpf == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "cpf required"})
return
}
// Clean CPF (optional, but good practice)
// For now rely on exact match or client cleaning.
// Ideally service should clean.
// Check existence
regiao := c.GetString("regiao")
exists, claimed, name, err := h.service.CheckExistence(c.Request.Context(), cpf, regiao)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Return limited info for public check
c.JSON(http.StatusOK, gin.H{
"exists": exists,
"claimed": claimed,
"name": name, // Returning name might be PII, but usually acceptable for confirmation "Is this you?"
})
}